Skip to main content

switchyard_protocol/
stream.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Streaming half of the neutral IR: incremental response chunks ([`LlmResponseChunk`]),
5//! their stream envelope ([`LlmResponseStreamEvent`]), and the streamed response
6//! ([`LlmResponse`]) that carries either a live stream or the terminal [`AggLlmResponse`].
7
8use std::collections::BTreeMap;
9use std::pin::Pin;
10
11use futures::{Stream, StreamExt};
12use http::StatusCode;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use thiserror::Error;
16
17use crate::{
18    LlmClientError,
19    format::FormatId,
20    llm::{AggLlmResponse, ContentBlock, ResponseOutput, Role, StopReason, ToolCall, Usage},
21};
22
23/// Status reported for an upstream error delivered inside a streaming body. The
24/// upstream already sent a success status line before failing, so there is no real
25/// code to propagate; 502 matches how a failed upstream call surfaces elsewhere.
26const MID_STREAM_UPSTREAM_STATUS: StatusCode = StatusCode::BAD_GATEWAY;
27
28/// Why a translated event stream stopped early.
29#[derive(Debug, Error)]
30pub enum LlmStreamError {
31    /// An upstream sse error
32    #[error("upstream stream error: {0}")]
33    Upstream(Value),
34
35    /// The stream itself failed
36    #[error(transparent)]
37    Client(#[from] LlmClientError),
38}
39
40/// A boxed, `Send` stream of response events. Each item may fail independently mid-stream.
41pub type LlmResponseStream =
42    Pin<Box<dyn Stream<Item = Result<LlmResponseStreamEvent, LlmClientError>> + Send>>;
43
44/// Parsed provider event retained for same-format replay.
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct ProviderStreamEvent {
47    source: FormatId,
48    raw: Value,
49}
50
51impl ProviderStreamEvent {
52    /// Source wire format of the retained event.
53    pub fn source(&self) -> &FormatId {
54        &self.source
55    }
56
57    /// Parsed provider JSON retained for replay.
58    pub fn raw(&self) -> &Value {
59        &self.raw
60    }
61
62    /// Consumes the preservation value into its source format and parsed JSON.
63    pub fn into_parts(self) -> (FormatId, Value) {
64        (self.source, self.raw)
65    }
66}
67
68/// One streaming item crossing the host/algorithm boundary.
69///
70/// `normalized` contains only provider-neutral chunks. `preservation` is opaque to
71/// algorithms and is interpreted by `switchyard-translation` for same-format replay.
72#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
73pub struct LlmResponseStreamEvent {
74    preservation: Option<ProviderStreamEvent>,
75    normalized: Vec<LlmResponseChunk>,
76}
77
78impl LlmResponseStreamEvent {
79    /// Creates an event containing only normalized chunks.
80    pub fn new(normalized: Vec<LlmResponseChunk>) -> Self {
81        Self {
82            preservation: None,
83            normalized,
84        }
85    }
86
87    /// Creates an event with parsed provider JSON retained for replay.
88    pub fn preserved(
89        source: impl Into<FormatId>,
90        raw: Value,
91        normalized: Vec<LlmResponseChunk>,
92    ) -> Self {
93        Self {
94            preservation: Some(ProviderStreamEvent {
95                source: source.into(),
96                raw,
97            }),
98            normalized,
99        }
100    }
101
102    /// Retained provider event, when this event came directly from a provider.
103    pub fn preservation(&self) -> Option<&ProviderStreamEvent> {
104        self.preservation.as_ref()
105    }
106
107    /// Provider-neutral chunks carried by this event.
108    pub fn normalized(&self) -> &[LlmResponseChunk] {
109        &self.normalized
110    }
111
112    /// Consumes the event into its preservation and normalized content.
113    pub fn into_parts(self) -> (Option<ProviderStreamEvent>, Vec<LlmResponseChunk>) {
114        (self.preservation, self.normalized)
115    }
116
117    /// Replaces semantic content and drops raw replay data that no longer describes it.
118    pub fn replace_normalized(self, normalized: Vec<LlmResponseChunk>) -> Self {
119        Self::new(normalized)
120    }
121}
122
123impl From<LlmResponseChunk> for LlmResponseStreamEvent {
124    fn from(chunk: LlmResponseChunk) -> Self {
125        Self::new(vec![chunk])
126    }
127}
128
129/// A model response: either a live [`Stream`](LlmResponse::Stream) of events or a
130/// terminal buffered [`LlmResponse::Agg`] response.
131///
132/// Not `Clone` — the `Stream` variant owns a single-consumption stream. A buffered
133/// backend returns `Agg` directly; a streaming one returns `Stream` and the consumer
134/// drives it, folding to an [`AggLlmResponse`] when it needs the whole response.
135#[allow(clippy::large_enum_variant)]
136pub enum LlmResponse {
137    /// Live, single-consumption response stream.
138    Stream(LlmResponseStream),
139    /// Fully buffered terminal response.
140    Agg(AggLlmResponse),
141}
142
143impl LlmResponse {
144    /// Borrow the aggregate; `None` while this is still a stream.
145    pub fn as_agg(&self) -> Option<&AggLlmResponse> {
146        match self {
147            LlmResponse::Agg(agg) => Some(agg),
148            LlmResponse::Stream(_) => None,
149        }
150    }
151
152    /// Reduce to the buffered aggregate: return an `Agg` unchanged, or drive a `Stream`
153    /// to completion, folding its normalized chunks into an [`AggLlmResponse`] via
154    /// [`ResponseAccumulator`]. A stream item error aborts with `Err`, as does an
155    /// in-band [`LlmResponseChunk::DecodeError`] (as `ResponseTranslation`) or
156    /// [`LlmResponseChunk::StreamError`] (as `UpstreamHttp`).
157    pub async fn into_agg(self) -> Result<AggLlmResponse, LlmClientError> {
158        match self {
159            LlmResponse::Agg(agg) => Ok(agg),
160            LlmResponse::Stream(mut stream) => {
161                let mut accumulator = ResponseAccumulator::new();
162                while let Some(item) = stream.next().await {
163                    for chunk in item?.normalized {
164                        push_checked_chunk(&mut accumulator, chunk)?;
165                    }
166                }
167                Ok(accumulator.finish())
168            }
169        }
170    }
171
172    /// Returns the model recorded by a buffered response.
173    ///
174    /// Returns `None` for a live stream because reading its model-bearing
175    /// [`LlmResponseChunk::MessageStart`] event would consume the stream.
176    pub fn selected_model(&self) -> Option<&str> {
177        match self {
178            LlmResponse::Agg(agg) => agg.model.as_deref(),
179            // TODO: How do we get the model name on a stream?
180            LlmResponse::Stream(_) => None,
181        }
182    }
183}
184
185/// An encrypted reasoning detail that names its provider item id but carries no payload yet.
186fn is_reasoning_id_announcement(detail: &Value) -> bool {
187    detail.get("type").and_then(Value::as_str) == Some("reasoning.encrypted")
188        && detail.get("data").is_none()
189}
190
191/// Appends a reasoning detail to an accumulated block. A Responses stream decoder announces a
192/// reasoning item's provider id (`{"type": "reasoning.encrypted", "id"}`) before the payload
193/// arrives; the payload detail then replaces that announcement so history holds one detail.
194fn push_reasoning_detail(details: &mut Vec<Value>, detail: Value) {
195    if detail.get("type").and_then(Value::as_str) == Some("reasoning.encrypted")
196        && let Some(id) = detail.get("id").and_then(Value::as_str)
197        && let Some(announcement) = details.iter_mut().find(|existing| {
198            is_reasoning_id_announcement(existing)
199                && existing.get("id").and_then(Value::as_str) == Some(id)
200        })
201    {
202        *announcement = detail;
203        return;
204    }
205    details.push(detail);
206}
207
208impl AggLlmResponse {
209    /// Converts a fully-buffered response into a synthetic chunk stream.
210    ///
211    /// Useful when a caller had to buffer the response (e.g. to judge it) but the
212    /// downstream expects `LlmResponse::Stream` — for instance when `stream: true` was
213    /// requested and the algorithm had to aggregate before it could return.
214    ///
215    /// This conversion is lossy: only text, reasoning, and tool-call content has
216    /// a synthetic chunk representation. Refusals, tool results, media, files,
217    /// unknown blocks, response extensions, and preservation metadata are omitted.
218    pub fn into_stream(self) -> LlmResponseStream {
219        let mut chunks: Vec<LlmResponseChunk> = Vec::new();
220        chunks.push(LlmResponseChunk::MessageStart {
221            id: self.id,
222            model: self.model,
223        });
224        let mut tool_call_index = 0usize;
225        for (output_index, output) in self.outputs.into_iter().enumerate() {
226            for block in output.content {
227                match block {
228                    ContentBlock::Text { text } => {
229                        chunks.push(LlmResponseChunk::TextDelta {
230                            index: output_index,
231                            text,
232                        });
233                    }
234                    ContentBlock::Reasoning { text, details, .. } => {
235                        if !details.is_empty() {
236                            chunks.push(LlmResponseChunk::ReasoningDetailsDelta {
237                                index: output_index,
238                                details,
239                                text,
240                            });
241                        } else {
242                            chunks.push(LlmResponseChunk::ReasoningDelta {
243                                index: output_index,
244                                text,
245                            });
246                        }
247                    }
248                    ContentBlock::ToolCall(tool) => {
249                        let args = serde_json::to_string(&tool.arguments).unwrap_or_default();
250                        chunks.push(LlmResponseChunk::ToolCallDelta {
251                            index: tool_call_index,
252                            id: Some(tool.id),
253                            name: Some(tool.name),
254                            arguments_delta: Some(args),
255                        });
256                        tool_call_index += 1;
257                    }
258                    // Other variants (Image, ToolResult, Refusal, etc.) don't have
259                    // a streaming chunk representation and don't appear in assistant outputs.
260                    _ => {}
261                }
262            }
263            chunks.push(LlmResponseChunk::MessageStop {
264                reason: output.stop_reason.and_then(|r| {
265                    serde_json::to_value(r)
266                        .ok()
267                        .and_then(|v| v.as_str().map(String::from))
268                }),
269            });
270        }
271        chunks.push(LlmResponseChunk::Usage(self.usage));
272        Box::pin(futures::stream::iter(
273            chunks.into_iter().map(|chunk| Ok(chunk.into())),
274        ))
275    }
276}
277
278fn push_checked_chunk(
279    accumulator: &mut ResponseAccumulator,
280    chunk: LlmResponseChunk,
281) -> Result<(), LlmClientError> {
282    match chunk {
283        LlmResponseChunk::DecodeError { message } => {
284            Err(LlmClientError::ResponseTranslation(message))
285        }
286        LlmResponseChunk::StreamError { message } => Err(LlmClientError::UpstreamHttp {
287            status: MID_STREAM_UPSTREAM_STATUS,
288            body: message,
289        }),
290        chunk => {
291            accumulator.push(chunk);
292            Ok(())
293        }
294    }
295}
296
297/// One provider-neutral streaming response chunk.
298#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
299pub enum LlmResponseChunk {
300    /// Starts a response message.
301    MessageStart {
302        /// Provider response identifier.
303        id: Option<String>,
304        /// Model reported by the provider.
305        model: Option<String>,
306    },
307    /// Adds text to one output index.
308    TextDelta {
309        /// Provider output index.
310        index: usize,
311        /// Text fragment.
312        text: String,
313    },
314    /// Adds reasoning text to one output index.
315    ReasoningDelta {
316        /// Provider output index.
317        index: usize,
318        /// Reasoning fragment.
319        text: String,
320    },
321    /// Adds structured reasoning details to one output index.
322    ReasoningDetailsDelta {
323        /// Provider output index.
324        index: usize,
325        /// Reasoning detail objects in provider order.
326        details: Vec<Value>,
327        /// Normalized reasoning text represented by or accompanying the details.
328        text: String,
329    },
330    /// Adds or updates a tool call at one index.
331    ToolCallDelta {
332        /// Tool-call index within the response.
333        index: usize,
334        /// Tool-call identifier, normally supplied by the first delta.
335        id: Option<String>,
336        /// Tool name, normally supplied by the first delta.
337        name: Option<String>,
338        /// Fragment of the serialized tool arguments.
339        arguments_delta: Option<String>,
340    },
341    /// Reports token usage, normally near the end of the stream.
342    Usage(Usage),
343    /// Ends a response message.
344    MessageStop {
345        /// Provider stop-reason string before normalization.
346        reason: Option<String>,
347    },
348    /// Reports that an inbound stream event could not be decoded.
349    DecodeError {
350        /// Human-readable decoding failure.
351        message: String,
352    },
353    /// Reports an upstream failure delivered inside an otherwise successful stream.
354    StreamError {
355        /// Human-readable upstream failure.
356        message: String,
357    },
358}
359
360/// Folds a sequence of [`LlmResponseChunk`]s into the terminal [`AggLlmResponse`].
361///
362/// Text and reasoning deltas concatenate; tool-call deltas assemble by index (name,
363/// id, and a growing arguments string parsed as JSON at the end); `MessageStart`,
364/// `Usage`, and `MessageStop` set the corresponding fields.
365/// [`DecodeError`](LlmResponseChunk::DecodeError) and
366/// [`StreamError`](LlmResponseChunk::StreamError) chunks are ignored here; use
367/// [`LlmResponse::into_agg`] when they must become errors.
368///
369/// Folding is lossy for multiple outputs: text and reasoning indices are ignored,
370/// and [`finish`](Self::finish) produces one assistant output. Prefer
371/// [`LlmResponse::into_agg`] when stream errors must be surfaced.
372///
373/// Drive it by `push`-ing each chunk in order, then call [`finish`](Self::finish).
374#[derive(Default)]
375pub struct ResponseAccumulator {
376    id: Option<String>,
377    model: Option<String>,
378    text: String,
379    reasoning: Option<String>,
380    reasoning_details: Vec<Value>,
381    tool_calls: BTreeMap<usize, PartialToolCall>,
382    usage: Usage,
383    stop_reason: Option<StopReason>,
384}
385
386/// A tool call being assembled from streamed [`LlmResponseChunk::ToolCallDelta`]s.
387#[derive(Default)]
388struct PartialToolCall {
389    id: Option<String>,
390    name: Option<String>,
391    arguments: String,
392}
393
394impl ResponseAccumulator {
395    /// A fresh accumulator with no chunks applied.
396    pub fn new() -> Self {
397        Self::default()
398    }
399
400    /// Apply one chunk. Later `MessageStart`/`Usage`/`MessageStop` fields overwrite
401    /// earlier ones; text, reasoning, and tool-call arguments append.
402    pub fn push(&mut self, chunk: LlmResponseChunk) {
403        match chunk {
404            LlmResponseChunk::MessageStart { id, model } => {
405                if id.is_some() {
406                    self.id = id;
407                }
408                if model.is_some() {
409                    self.model = model;
410                }
411            }
412            LlmResponseChunk::TextDelta { text, .. } => self.text.push_str(&text),
413            LlmResponseChunk::ReasoningDelta { text, .. } => {
414                self.reasoning
415                    .get_or_insert_with(String::new)
416                    .push_str(&text);
417            }
418            LlmResponseChunk::ReasoningDetailsDelta { details, text, .. } => {
419                for detail in details {
420                    push_reasoning_detail(&mut self.reasoning_details, detail);
421                }
422                if !text.is_empty() {
423                    self.reasoning
424                        .get_or_insert_with(String::new)
425                        .push_str(&text);
426                }
427            }
428            LlmResponseChunk::ToolCallDelta {
429                index,
430                id,
431                name,
432                arguments_delta,
433            } => {
434                let call = self.tool_calls.entry(index).or_default();
435                if id.is_some() {
436                    call.id = id;
437                }
438                if name.is_some() {
439                    call.name = name;
440                }
441                if let Some(delta) = arguments_delta {
442                    call.arguments.push_str(&delta);
443                }
444            }
445            LlmResponseChunk::Usage(usage) => self.usage = usage,
446            LlmResponseChunk::MessageStop { reason } => {
447                self.stop_reason = Some(stop_reason_from_str(reason.as_deref()));
448            }
449            LlmResponseChunk::DecodeError { .. } | LlmResponseChunk::StreamError { .. } => {}
450        }
451    }
452
453    /// Build the buffered response. Content is ordered reasoning, then text, then
454    /// tool calls (by ascending delta index) — a single assistant output.
455    pub fn finish(self) -> AggLlmResponse {
456        let mut content = Vec::new();
457        if self.reasoning.is_some() || !self.reasoning_details.is_empty() {
458            content.push(ContentBlock::Reasoning {
459                text: self.reasoning.unwrap_or_default(),
460                signature: None,
461                // An announcement whose payload never arrived is a stream-level hint only.
462                details: self
463                    .reasoning_details
464                    .into_iter()
465                    .filter(|detail| !is_reasoning_id_announcement(detail))
466                    .collect(),
467            });
468        }
469        if !self.text.is_empty() {
470            content.push(ContentBlock::Text { text: self.text });
471        }
472        for call in self.tool_calls.into_values() {
473            content.push(ContentBlock::ToolCall(ToolCall {
474                id: call.id.unwrap_or_default(),
475                name: call.name.unwrap_or_default(),
476                arguments: parse_tool_arguments(&call.arguments),
477            }));
478        }
479        AggLlmResponse {
480            id: self.id,
481            model: self.model,
482            outputs: vec![ResponseOutput {
483                role: Role::Assistant,
484                content,
485                stop_reason: self.stop_reason,
486            }],
487            usage: self.usage,
488            ..AggLlmResponse::default()
489        }
490    }
491}
492
493/// Parse an assembled tool-call arguments string as JSON, falling back to a JSON
494/// string when it is not valid JSON and to an empty object when it is empty.
495fn parse_tool_arguments(arguments: &str) -> Value {
496    if arguments.is_empty() {
497        return Value::Object(serde_json::Map::new());
498    }
499    serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
500}
501
502/// Map a provider stop-reason string (as carried by [`LlmResponseChunk::MessageStop`])
503/// to a normalized [`StopReason`], covering the common OpenAI and Anthropic spellings.
504fn stop_reason_from_str(reason: Option<&str>) -> StopReason {
505    match reason {
506        Some("length" | "max_tokens") => StopReason::MaxTokens,
507        Some("tool_calls" | "function_call" | "tool_use") => StopReason::ToolUse,
508        Some("content_filter") => StopReason::ContentFilter,
509        Some("stop" | "end_turn" | "stop_sequence") | None => StopReason::EndTurn,
510        Some(_) => StopReason::Unknown,
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use futures::executor::block_on;
517    use futures::stream;
518
519    use super::*;
520    use serde_json::json;
521
522    fn fold(chunks: Vec<LlmResponseChunk>) -> AggLlmResponse {
523        let mut accumulator = ResponseAccumulator::new();
524        for chunk in chunks {
525            accumulator.push(chunk);
526        }
527        accumulator.finish()
528    }
529
530    #[test]
531    fn folds_text_usage_and_stop_reason() {
532        let agg = fold(vec![
533            LlmResponseChunk::MessageStart {
534                id: Some("id1".to_string()),
535                model: Some("m".to_string()),
536            },
537            LlmResponseChunk::TextDelta {
538                index: 0,
539                text: "Hel".to_string(),
540            },
541            LlmResponseChunk::TextDelta {
542                index: 0,
543                text: "lo".to_string(),
544            },
545            LlmResponseChunk::Usage(Usage {
546                output_tokens: Some(2),
547                ..Usage::default()
548            }),
549            LlmResponseChunk::MessageStop {
550                reason: Some("length".to_string()),
551            },
552        ]);
553        assert_eq!(agg.id.as_deref(), Some("id1"));
554        assert_eq!(agg.model.as_deref(), Some("m"));
555        assert_eq!(agg.usage.output_tokens, Some(2));
556        assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::MaxTokens));
557        assert_eq!(
558            agg.outputs[0].content,
559            vec![ContentBlock::Text {
560                text: "Hello".to_string()
561            }]
562        );
563    }
564
565    #[test]
566    fn aggregates_normalized_chunks_inside_stream_event() {
567        let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
568            LlmResponseStreamEvent::preserved(
569                crate::WireFormat::OpenAiChat,
570                json!({
571                "choices": [{"delta": {"content": "hello"}}],
572                "system_fingerprint": "fp_exact"
573                }),
574                vec![LlmResponseChunk::TextDelta {
575                    index: 0,
576                    text: "hello".to_string(),
577                }],
578            ),
579        )])));
580        let aggregate = block_on(response.into_agg()).expect("stream event should aggregate");
581
582        assert_eq!(
583            aggregate.outputs[0].content,
584            vec![ContentBlock::Text {
585                text: "hello".to_string()
586            }]
587        );
588    }
589
590    #[test]
591    fn replacing_normalized_content_drops_preservation() {
592        let event = LlmResponseStreamEvent::preserved(
593            crate::WireFormat::OpenAiChat,
594            json!({"choices": [{"delta": {"content": "old"}}]}),
595            vec![LlmResponseChunk::TextDelta {
596                index: 0,
597                text: "old".to_string(),
598            }],
599        )
600        .replace_normalized(vec![LlmResponseChunk::TextDelta {
601            index: 0,
602            text: "new".to_string(),
603        }]);
604
605        assert!(event.preservation().is_none());
606        assert_eq!(
607            event.normalized(),
608            &[LlmResponseChunk::TextDelta {
609                index: 0,
610                text: "new".to_string(),
611            }]
612        );
613    }
614
615    #[test]
616    fn stream_errors_inside_preserved_events_remain_typed() {
617        let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
618            LlmResponseStreamEvent::preserved(
619                crate::WireFormat::OpenAiChat,
620                json!({"error": {"message": "provider failed"}}),
621                vec![LlmResponseChunk::StreamError {
622                    message: "provider failed".to_string(),
623                }],
624            ),
625        )])));
626
627        let error = block_on(response.into_agg()).err();
628        assert!(matches!(
629            error,
630            Some(LlmClientError::UpstreamHttp {
631                status: MID_STREAM_UPSTREAM_STATUS,
632                ..
633            })
634        ));
635    }
636
637    #[test]
638    fn assembles_tool_calls_by_index() {
639        // id/name arrive once, arguments stream across deltas and parse as JSON.
640        let agg = fold(vec![
641            LlmResponseChunk::ToolCallDelta {
642                index: 0,
643                id: Some("call_1".to_string()),
644                name: Some("lookup".to_string()),
645                arguments_delta: Some("{\"q\":".to_string()),
646            },
647            LlmResponseChunk::ToolCallDelta {
648                index: 0,
649                id: None,
650                name: None,
651                arguments_delta: Some("\"rust\"}".to_string()),
652            },
653            LlmResponseChunk::MessageStop {
654                reason: Some("tool_calls".to_string()),
655            },
656        ]);
657        assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::ToolUse));
658        assert_eq!(
659            agg.outputs[0].content,
660            vec![ContentBlock::ToolCall(ToolCall {
661                id: "call_1".to_string(),
662                name: "lookup".to_string(),
663                arguments: json!({"q": "rust"}),
664            })]
665        );
666    }
667
668    #[test]
669    fn reasoning_precedes_text_in_content() {
670        let agg = fold(vec![
671            LlmResponseChunk::ReasoningDelta {
672                index: 0,
673                text: "think".to_string(),
674            },
675            LlmResponseChunk::TextDelta {
676                index: 0,
677                text: "answer".to_string(),
678            },
679        ]);
680        assert_eq!(
681            agg.outputs[0].content,
682            vec![
683                ContentBlock::Reasoning {
684                    text: "think".to_string(),
685                    signature: None,
686                    details: Vec::new(),
687                },
688                ContentBlock::Text {
689                    text: "answer".to_string(),
690                },
691            ]
692        );
693    }
694
695    #[test]
696    fn into_stream_round_trips_through_into_agg() {
697        let original = AggLlmResponse {
698            id: Some("id1".to_string()),
699            model: Some("m".to_string()),
700            outputs: vec![ResponseOutput {
701                role: Role::Assistant,
702                content: vec![ContentBlock::Text {
703                    text: "hello".to_string(),
704                }],
705                stop_reason: Some(StopReason::EndTurn),
706            }],
707            usage: Usage {
708                output_tokens: Some(3),
709                ..Usage::default()
710            },
711            ..AggLlmResponse::default()
712        };
713        let stream = LlmResponse::Stream(original.clone().into_stream());
714        let recovered = block_on(stream.into_agg()).expect("into_agg failed");
715        assert_eq!(recovered.id, original.id);
716        assert_eq!(recovered.model, original.model);
717        assert_eq!(recovered.usage.output_tokens, original.usage.output_tokens);
718        assert_eq!(
719            recovered.outputs[0].stop_reason,
720            original.outputs[0].stop_reason
721        );
722        assert_eq!(recovered.outputs[0].content, original.outputs[0].content);
723    }
724
725    #[test]
726    fn into_stream_retains_encrypted_reasoning_and_text() {
727        let details = vec![json!({
728            "type": "reasoning.encrypted",
729            "data": "opaque-encrypted-reasoning"
730        })];
731        let original = AggLlmResponse {
732            outputs: vec![ResponseOutput {
733                role: Role::Assistant,
734                content: vec![ContentBlock::Reasoning {
735                    text: "fallback reasoning".to_string(),
736                    signature: None,
737                    details: details.clone(),
738                }],
739                stop_reason: Some(StopReason::EndTurn),
740            }],
741            ..AggLlmResponse::default()
742        };
743
744        let recovered = block_on(LlmResponse::Stream(original.into_stream()).into_agg())
745            .expect("into_agg failed");
746        let ContentBlock::Reasoning {
747            text,
748            details: recovered_details,
749            ..
750        } = &recovered.outputs[0].content[0]
751        else {
752            panic!("expected reasoning block");
753        };
754        assert_eq!(text, "fallback reasoning");
755        assert_eq!(recovered_details, &details);
756    }
757
758    #[test]
759    fn into_agg_preserves_stream_item_error() {
760        let response = LlmResponse::Stream(Box::pin(stream::once(async {
761            Err(LlmClientError::Timeout {
762                source: Box::new(std::io::Error::other("timed out")),
763            })
764        })));
765
766        let Err(error) = block_on(response.into_agg()) else {
767            panic!("expected stream aggregation to fail");
768        };
769        assert!(matches!(error, LlmClientError::Timeout { .. }));
770    }
771}