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    /// Stable `snake_case` label naming this failure class on the wire.
239    ///
240    /// Durable runtimes persist this on a task's terminal reason and
241    /// republish it as `TerminalReason.provider_error_kind`, so operators
242    /// can group terminals by provider failure class. The match is
243    /// exhaustive **inside this crate** — `#[non_exhaustive]` only binds
244    /// downstream — so adding a variant is a compile error here rather
245    /// than a silent relabel at a distant consumer.
246    ///
247    /// The rate-limit delay hint is deliberately dropped: the label is a
248    /// grouping key, and folding a per-response duration into it would
249    /// produce an unbounded cardinality of "kinds".
250    #[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/// Classify a typed HTTP client failure without relying on display text.
264#[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
294/// `true` when a TLS peer answered the handshake and rejected the session
295/// (certificate validation, hostname mismatch, protocol or cipher
296/// negotiation, an interception proxy presenting the wrong identity, …).
297///
298/// A peer that speaks TLS at us is reachable, so these are bounded server
299/// errors, never connectivity waits: retrying cannot fix a policy or
300/// configuration rejection, and misreading one as "offline" would park the
301/// caller in an indefinite wait on a failure that is deterministic. The one
302/// exception is a TLS-wrapped *transport* death — a socket that EOFs or
303/// resets mid-handshake — which stays on the connectivity path.
304fn 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
367/// Type alias for a boxed stream of stream deltas.
368pub type StreamBox<'a> = Pin<Box<dyn Stream<Item = anyhow::Result<StreamDelta>> + Send + 'a>>;
369
370/// Sum two usage readings, saturating so token counters never wrap.
371fn 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/// Preserves usage across a stream-splicing wrapper's attempt boundary.
388///
389/// A wrapper that abandons one inner stream and splices in another (failover,
390/// credential refresh) has a hazard: [`StreamAccumulator`] keeps only the LAST
391/// `Usage` delta it sees, so the retried stream's usage would erase the
392/// abandoned one — silently un-billing tokens the provider already charged for.
393///
394/// The carry closes that: on every outgoing `Usage` delta, the wrapper calls
395/// [`running_total`](Self::running_total) to rewrite it to the sum of all
396/// abandoned attempts plus this stream's latest reading; when it abandons a
397/// stream to retry, it calls [`abandon`](Self::abandon) to fold that stream's
398/// usage into the carry. The final delta the consumer sees — the only one the
399/// accumulator keeps — is therefore the true total across every attempt.
400#[derive(Default)]
401pub(crate) struct UsageCarry {
402    /// Usage billed by abandoned attempts.
403    carried: Option<Usage>,
404    /// The current attempt's latest usage reading (last-wins within an attempt).
405    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    /// Record `usage` as the current attempt's reading and return the running
417    /// total (abandoned attempts + this reading) to yield in its place.
418    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    /// Fold the current attempt's usage into the carry because its stream is
425    /// being abandoned for a retry.
426    pub(crate) fn abandon(&mut self) {
427        if let Some(current) = self.current.take() {
428            self.carried = Some(add_usage(self.carried.as_ref(), &current));
429        }
430    }
431}
432
433/// Helper to accumulate streamed content into a final response.
434///
435/// This struct collects [`StreamDelta`] events and can convert them
436/// into the final content blocks once the stream is complete.
437#[derive(Debug, Default)]
438pub struct StreamAccumulator {
439    /// Accumulated text for each block index
440    text_blocks: Vec<String>,
441    /// Accumulated thinking blocks for each block index
442    thinking_blocks: Vec<String>,
443    /// Accumulated signatures keyed by block index
444    thinking_signatures: HashMap<usize, String>,
445    /// Redacted thinking blocks: (`block_index`, data)
446    redacted_thinking_blocks: Vec<(usize, String)>,
447    /// Provider-owned opaque reasoning: (`block_index`, provider, data)
448    opaque_reasoning_blocks: Vec<(usize, String, serde_json::Value)>,
449    /// Accumulated tool use calls
450    tool_uses: Vec<ToolUseAccumulator>,
451    /// Usage information from the stream
452    usage: Option<Usage>,
453    /// Stop reason from the stream
454    stop_reason: Option<StopReason>,
455}
456
457/// Accumulator for a single tool use during streaming.
458#[derive(Debug, Default)]
459pub struct ToolUseAccumulator {
460    /// Tool call ID
461    pub id: String,
462    /// Tool name
463    pub name: String,
464    /// Accumulated JSON input (may be incomplete during streaming)
465    pub input_json: String,
466    /// Block index for ordering
467    pub block_index: usize,
468    /// Optional thought signature (used by Gemini 3.x models)
469    pub thought_signature: Option<String>,
470}
471
472impl StreamAccumulator {
473    /// Create a new empty accumulator.
474    #[must_use]
475    pub fn new() -> Self {
476        Self::default()
477    }
478
479    /// Apply a stream delta to the accumulator.
480    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    /// Get the accumulated usage information.
554    #[must_use]
555    pub const fn usage(&self) -> Option<&Usage> {
556        self.usage.as_ref()
557    }
558
559    /// Get the stop reason.
560    #[must_use]
561    pub const fn stop_reason(&self) -> Option<&StopReason> {
562        self.stop_reason.as_ref()
563    }
564
565    /// Convert accumulated content to `ContentBlock`s.
566    ///
567    /// This consumes the accumulator and returns the final content blocks.
568    /// Tool use JSON is parsed at this point; invalid JSON results in a null input.
569    #[must_use]
570    pub fn into_content_blocks(self) -> Vec<ContentBlock> {
571        let mut blocks: Vec<(usize, ContentBlock)> = Vec::new();
572
573        // Add thinking blocks with their indices, attaching signatures
574        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        // Add redacted thinking blocks
589        for (idx, data) in self.redacted_thinking_blocks {
590            blocks.push((idx, ContentBlock::RedactedThinking { data }));
591        }
592
593        // Add provider-owned reasoning state without interpreting its payload.
594        for (idx, provider, data) in self.opaque_reasoning_blocks {
595            blocks.push((idx, ContentBlock::OpaqueReasoning { provider, data }));
596        }
597
598        // Add text blocks with their indices
599        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        // Add tool uses with their indices
606        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        // Sort by block index to maintain order
632        blocks.sort_by_key(|(idx, _)| *idx);
633
634        blocks.into_iter().map(|(_, block)| block).collect()
635    }
636
637    /// Take ownership of accumulated usage.
638    pub const fn take_usage(&mut self) -> Option<Usage> {
639        self.usage.take()
640    }
641
642    /// Take ownership of stop reason.
643    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        // If no ToolInputDelta is received (e.g., stream interrupted or
845        // deltas had mismatched IDs), the tool use block should still be
846        // produced with an empty object so that the error is attributable
847        // to the tool rather than silently lost.
848        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        // No ToolInputDelta applied
857
858        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        // If ToolInputDelta has a different ID than any ToolUseStart,
872        // the input is silently dropped (the tool gets empty {}).
873        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        // Delta with wrong ID
882        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                // Input should be empty because the delta had a mismatched ID
893                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        // A hostile/corrupted event with a huge block_index must not drive an
927        // unbounded Vec allocation. The delta is dropped, leaving the accumulator
928        // tiny rather than allocating billions of empty Strings.
929        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    /// A live TLS peer that fails certificate validation is a policy
1015    /// rejection, not an outage — waiting for connectivity cannot fix it, so
1016    /// it must stay on the bounded path. The server presents a self-signed
1017    /// certificate the client refuses; the assertion also pins the
1018    /// `native_tls::Error` downcast in `is_tls_rejection` against a version
1019    /// drift between reqwest's native-tls and the workspace's.
1020    #[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                // The client aborts after rejecting the certificate, so the
1079                // server-side handshake result is an expected error.
1080                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        // "café" — the 'é' is the two bytes 0xC3 0xA9. Split the chunk boundary
1154        // *inside* that character: the naive per-chunk from_utf8_lossy would emit
1155        // a U+FFFD replacement char; the line buffer must decode it losslessly
1156        // because it only decodes the complete line.
1157        let mut buf = SseLineBuffer::new();
1158        let line = "data: café\n";
1159        let bytes = line.as_bytes();
1160        let split = bytes.len() - 2; // between 0xC3 and 0xA9
1161        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}