Skip to main content

rig_core/
streaming.rs

1//! This module provides functionality for working with streaming completion models.
2//! It provides traits and types for generating streaming completion requests and
3//! handling streaming completion responses.
4//!
5//! Provider implementations use these types to expose raw streamed completion
6//! events without depending on a runtime.
7
8use crate::OneOrMany;
9use crate::completion::{CompletionError, CompletionResponse, GetTokenUsage, Usage};
10use crate::message::{
11    AssistantContent, Reasoning, ReasoningContent, Text, ToolCall, ToolFunction, ToolResult,
12};
13use futures::stream::{AbortHandle, Abortable};
14use futures::{Stream, StreamExt};
15use serde::{Deserialize, Serialize};
16use std::pin::Pin;
17use std::sync::atomic::AtomicBool;
18use std::task::{Context, Poll};
19use tokio::sync::watch;
20
21/// Control for pausing and resuming a streaming response
22pub struct PauseControl {
23    pub(crate) paused_tx: watch::Sender<bool>,
24    pub(crate) paused_rx: watch::Receiver<bool>,
25}
26
27impl PauseControl {
28    /// Create a pause controller in the running state.
29    pub fn new() -> Self {
30        let (paused_tx, paused_rx) = watch::channel(false);
31        Self {
32            paused_tx,
33            paused_rx,
34        }
35    }
36
37    /// Pause polling of the public stream until [`PauseControl::resume`] is called.
38    pub fn pause(&self) {
39        let _ = self.paused_tx.send(true);
40    }
41
42    /// Resume polling after a pause.
43    pub fn resume(&self) {
44        let _ = self.paused_tx.send(false);
45    }
46
47    /// Returns whether the stream is currently paused.
48    pub fn is_paused(&self) -> bool {
49        *self.paused_rx.borrow()
50    }
51}
52
53impl Default for PauseControl {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59/// The content of a tool call delta - either the tool name or argument data
60#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
61pub enum ToolCallDeltaContent {
62    /// Tool/function name emitted by the provider.
63    Name(String),
64    /// Partial JSON argument data emitted by the provider.
65    Delta(String),
66}
67
68/// Enum representing a streaming chunk from the model
69#[derive(Debug, Clone)]
70pub enum RawStreamingChoice<R>
71where
72    R: Clone,
73{
74    /// A text chunk from a message response
75    Message(String),
76
77    /// Start a new text content block in the accumulated final choice.
78    ///
79    /// This is an internal provider-normalization event. It is not yielded to
80    /// public stream consumers, but lets providers preserve metadata boundaries
81    /// for final aggregated assistant text blocks.
82    TextStart {
83        /// Provider-specific metadata attached to this text block.
84        additional_params: Option<serde_json::Value>,
85    },
86
87    /// Provider-specific metadata for the current text content block.
88    ///
89    /// This is not yielded to public stream consumers. The metadata is merged
90    /// into the current aggregated [`Text`] block.
91    TextAdditionalParams(serde_json::Value),
92
93    /// A tool call response (in its entirety)
94    ToolCall(RawStreamingToolCall),
95    /// A tool call partial/delta
96    ToolCallDelta {
97        /// Provider-supplied tool call ID.
98        id: String,
99        /// Rig-generated unique identifier for this tool call.
100        internal_call_id: String,
101        content: ToolCallDeltaContent,
102    },
103    /// A reasoning (in its entirety)
104    Reasoning {
105        /// Provider-supplied reasoning block ID, when present.
106        id: Option<String>,
107        /// Complete reasoning content block.
108        content: ReasoningContent,
109    },
110    /// A reasoning partial/delta
111    ReasoningDelta {
112        /// Provider-supplied reasoning block ID, when present.
113        id: Option<String>,
114        /// Partial reasoning text.
115        reasoning: String,
116    },
117
118    /// The final response object, must be yielded if you want the
119    /// `response` field to be populated on the `StreamingCompletionResponse`
120    FinalResponse(R),
121
122    /// Provider-assigned message ID (e.g. OpenAI Responses API `msg_` ID).
123    /// Captured silently into `StreamingCompletionResponse::message_id`.
124    MessageId(String),
125
126    /// A provider-native output item this version does not model — e.g. an
127    /// OpenAI Responses hosted-tool result (`web_search_call`, `file_search_call`,
128    /// `computer_call`, `code_interpreter_call`). Carries the raw item object
129    /// verbatim. Forwarded to the stream consumer as
130    /// [`StreamedAssistantContent::Unknown`] but not folded into the accumulated
131    /// assistant message (there is no `AssistantContent::Unknown` history slot).
132    Unknown(serde_json::Value),
133}
134
135/// Describes a streaming tool call response (in its entirety)
136#[derive(Debug, Clone)]
137pub struct RawStreamingToolCall {
138    /// Provider-supplied tool call ID.
139    pub id: String,
140    /// Rig-generated unique identifier for this tool call.
141    pub internal_call_id: String,
142    /// Provider-specific call ID used by some APIs for tool result correlation.
143    pub call_id: Option<String>,
144    /// Tool/function name.
145    pub name: String,
146    /// Parsed tool arguments.
147    pub arguments: serde_json::Value,
148    /// Optional provider signature associated with the tool call.
149    pub signature: Option<String>,
150    /// Additional provider-specific tool call metadata.
151    pub additional_params: Option<serde_json::Value>,
152}
153
154impl RawStreamingToolCall {
155    /// Create an empty tool call accumulator for provider streaming parsers.
156    pub fn empty() -> Self {
157        Self {
158            id: String::new(),
159            internal_call_id: crate::id::generate(),
160            call_id: None,
161            name: String::new(),
162            arguments: serde_json::Value::Null,
163            signature: None,
164            additional_params: None,
165        }
166    }
167
168    /// Create a complete tool call with a generated internal call ID.
169    pub fn new(id: String, name: String, arguments: serde_json::Value) -> Self {
170        Self {
171            id,
172            internal_call_id: crate::id::generate(),
173            call_id: None,
174            name,
175            arguments,
176            signature: None,
177            additional_params: None,
178        }
179    }
180
181    /// Override the generated internal call ID.
182    pub fn with_internal_call_id(mut self, internal_call_id: String) -> Self {
183        self.internal_call_id = internal_call_id;
184        self
185    }
186
187    /// Attach a provider-specific call ID.
188    pub fn with_call_id(mut self, call_id: String) -> Self {
189        self.call_id = Some(call_id);
190        self
191    }
192
193    /// Attach or clear a provider signature.
194    pub fn with_signature(mut self, signature: Option<String>) -> Self {
195        self.signature = signature;
196        self
197    }
198
199    /// Attach provider-specific metadata.
200    pub fn with_additional_params(mut self, additional_params: Option<serde_json::Value>) -> Self {
201        self.additional_params = additional_params;
202        self
203    }
204}
205
206impl From<RawStreamingToolCall> for ToolCall {
207    fn from(tool_call: RawStreamingToolCall) -> Self {
208        ToolCall {
209            id: tool_call.id,
210            call_id: tool_call.call_id,
211            function: ToolFunction {
212                name: tool_call.name,
213                arguments: tool_call.arguments,
214            },
215            signature: tool_call.signature,
216            additional_params: tool_call.additional_params,
217        }
218    }
219}
220
221#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
222/// Provider stream of raw completion chunks on native targets.
223pub type StreamingResult<R> =
224    Pin<Box<dyn Stream<Item = Result<RawStreamingChoice<R>, CompletionError>> + Send>>;
225
226#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
227/// Provider stream of raw completion chunks on wasm targets.
228pub type StreamingResult<R> =
229    Pin<Box<dyn Stream<Item = Result<RawStreamingChoice<R>, CompletionError>>>>;
230
231/// The response from a streaming completion request;
232/// message and response are populated at the end of the
233/// `inner` stream.
234pub struct StreamingCompletionResponse<R>
235where
236    R: Clone + Unpin + GetTokenUsage,
237{
238    pub(crate) inner: Abortable<StreamingResult<R>>,
239    pub(crate) abort_handle: AbortHandle,
240    pub(crate) pause_control: PauseControl,
241    assistant_items: Vec<AssistantContent>,
242    text_item_index: Option<usize>,
243    reasoning_item_index: Option<usize>,
244    /// The final aggregated message from the stream
245    /// contains all text and tool calls generated
246    pub choice: OneOrMany<AssistantContent>,
247    /// The final response from the stream, may be `None`
248    /// if the provider didn't yield it during the stream
249    pub response: Option<R>,
250    pub final_response_yielded: AtomicBool,
251    /// Provider-assigned message ID (e.g. OpenAI Responses API `msg_` ID).
252    pub message_id: Option<String>,
253}
254
255impl<R> StreamingCompletionResponse<R>
256where
257    R: Clone + Unpin + GetTokenUsage,
258{
259    /// Wrap a provider stream and initialize aggregation state.
260    pub fn stream(inner: StreamingResult<R>) -> StreamingCompletionResponse<R> {
261        let (abort_handle, abort_registration) = AbortHandle::new_pair();
262        let abortable_stream = Abortable::new(inner, abort_registration);
263        let pause_control = PauseControl::new();
264        Self {
265            inner: abortable_stream,
266            abort_handle,
267            pause_control,
268            assistant_items: vec![],
269            text_item_index: None,
270            reasoning_item_index: None,
271            choice: OneOrMany::one(AssistantContent::text("")),
272            response: None,
273            final_response_yielded: AtomicBool::new(false),
274            message_id: None,
275        }
276    }
277
278    /// Cancel the stream and immediately drop the provider's inner stream.
279    /// Cancellation is surfaced as normal stream termination.
280    pub fn cancel(&mut self) {
281        self.abort_handle.abort();
282        let (abort_handle, abort_registration) = AbortHandle::new_pair();
283        let empty: StreamingResult<R> = Box::pin(futures::stream::poll_fn(|_| Poll::Ready(None)));
284        self.inner = Abortable::new(empty, abort_registration);
285        self.abort_handle = abort_handle;
286    }
287
288    /// Pause stream polling.
289    pub fn pause(&self) {
290        self.pause_control.pause();
291    }
292
293    /// Resume stream polling after a pause.
294    pub fn resume(&self) {
295        self.pause_control.resume();
296    }
297
298    /// Returns whether the stream is currently paused.
299    pub fn is_paused(&self) -> bool {
300        self.pause_control.is_paused()
301    }
302
303    /// Token usage reported by the provider for this response.
304    ///
305    /// Returns the usage carried by the final response once the stream has
306    /// produced it. Until then — or when the provider does not report streamed
307    /// usage — this returns [`Usage::new`], the zero-valued sentinel for missing
308    /// usage metrics.
309    pub fn usage(&self) -> Usage {
310        self.response.token_usage()
311    }
312
313    fn append_text_chunk(&mut self, text: &str) {
314        if let Some(index) = self.text_item_index
315            && let Some(AssistantContent::Text(existing_text)) = self.assistant_items.get_mut(index)
316        {
317            existing_text.text.push_str(text);
318            return;
319        }
320
321        self.assistant_items
322            .push(AssistantContent::text(text.to_owned()));
323        self.text_item_index = Some(self.assistant_items.len() - 1);
324    }
325
326    fn append_text_additional_params(&mut self, additional_params: serde_json::Value) {
327        if additional_params.is_null() {
328            return;
329        }
330
331        let index = if let Some(index) = self.text_item_index
332            && matches!(
333                self.assistant_items.get(index),
334                Some(AssistantContent::Text(_))
335            ) {
336            index
337        } else {
338            self.assistant_items.push(AssistantContent::text(""));
339            let index = self.assistant_items.len() - 1;
340            self.text_item_index = Some(index);
341            index
342        };
343
344        let Some(AssistantContent::Text(text)) = self.assistant_items.get_mut(index) else {
345            return;
346        };
347
348        match text.additional_params.as_mut() {
349            Some(existing) => merge_text_additional_params(existing, additional_params),
350            None => text.additional_params = Some(additional_params),
351        }
352    }
353
354    /// Accumulate streaming reasoning delta text into assistant_items.
355    /// Providers that only emit ReasoningDelta (not full Reasoning blocks)
356    /// need this so the aggregated response includes reasoning content.
357    fn append_reasoning_chunk(&mut self, id: &Option<String>, text: &str) {
358        if let Some(index) = self.reasoning_item_index
359            && let Some(AssistantContent::Reasoning(existing)) = self.assistant_items.get_mut(index)
360            && let Some(ReasoningContent::Text {
361                text: existing_text,
362                ..
363            }) = existing.content.last_mut()
364        {
365            existing_text.push_str(text);
366            return;
367        }
368
369        self.assistant_items
370            .push(AssistantContent::Reasoning(Reasoning {
371                id: id.clone(),
372                content: vec![ReasoningContent::Text {
373                    text: text.to_string(),
374                    signature: None,
375                }],
376            }));
377        self.reasoning_item_index = Some(self.assistant_items.len() - 1);
378    }
379}
380
381fn merge_text_additional_params(existing: &mut serde_json::Value, incoming: serde_json::Value) {
382    match (existing, incoming) {
383        (serde_json::Value::Object(existing_map), serde_json::Value::Object(incoming_map)) => {
384            for (key, incoming_value) in incoming_map {
385                match existing_map.get_mut(&key) {
386                    Some(existing_value) => match (existing_value, incoming_value) {
387                        (
388                            serde_json::Value::Array(existing_array),
389                            serde_json::Value::Array(mut incoming_array),
390                        ) => existing_array.append(&mut incoming_array),
391                        (existing_value, incoming_value) => {
392                            merge_text_additional_params(existing_value, incoming_value);
393                        }
394                    },
395                    None => {
396                        existing_map.insert(key, incoming_value);
397                    }
398                }
399            }
400        }
401        (existing, incoming) => {
402            *existing = incoming;
403        }
404    }
405}
406
407impl<R> From<StreamingCompletionResponse<R>> for CompletionResponse<Option<R>>
408where
409    R: Clone + Unpin + GetTokenUsage,
410{
411    fn from(value: StreamingCompletionResponse<R>) -> CompletionResponse<Option<R>> {
412        CompletionResponse {
413            choice: value.choice,
414            // Derive usage from the final response. `Option<R>: GetTokenUsage`
415            // yields the provider's usage when present and the zero sentinel
416            // (`Usage::new`) when the stream produced no final response.
417            usage: value.response.token_usage(),
418            raw_response: value.response,
419            message_id: value.message_id,
420        }
421    }
422}
423
424impl<R> Stream for StreamingCompletionResponse<R>
425where
426    R: Clone + Unpin + GetTokenUsage,
427{
428    type Item = Result<StreamedAssistantContent<R>, CompletionError>;
429
430    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
431        let stream = self.get_mut();
432
433        if stream.is_paused() {
434            cx.waker().wake_by_ref();
435            return Poll::Pending;
436        }
437
438        match Pin::new(&mut stream.inner).poll_next(cx) {
439            Poll::Pending => Poll::Pending,
440            Poll::Ready(None) => {
441                // This is run at the end of the inner stream to collect all tokens into
442                // a single unified `Message`.
443                if stream.assistant_items.is_empty() {
444                    stream.assistant_items.push(AssistantContent::text(""));
445                }
446
447                if let Some(choice) =
448                    OneOrMany::from_iter_optional(std::mem::take(&mut stream.assistant_items))
449                {
450                    stream.choice = choice;
451                }
452
453                Poll::Ready(None)
454            }
455            Poll::Ready(Some(Err(err))) => {
456                if matches!(err, CompletionError::ProviderError(ref e) if e.to_string().contains("aborted"))
457                {
458                    return Poll::Ready(None); // Treat cancellation as stream termination
459                }
460                Poll::Ready(Some(Err(err)))
461            }
462            Poll::Ready(Some(Ok(choice))) => match choice {
463                RawStreamingChoice::Message(text) => {
464                    stream.reasoning_item_index = None;
465                    stream.append_text_chunk(&text);
466                    Poll::Ready(Some(Ok(StreamedAssistantContent::text(&text))))
467                }
468                RawStreamingChoice::TextStart { additional_params } => {
469                    stream.reasoning_item_index = None;
470                    stream.text_item_index = None;
471                    if let Some(additional_params) = additional_params {
472                        stream.append_text_additional_params(additional_params);
473                    }
474                    stream.poll_next_unpin(cx)
475                }
476                RawStreamingChoice::TextAdditionalParams(additional_params) => {
477                    stream.append_text_additional_params(additional_params);
478                    stream.poll_next_unpin(cx)
479                }
480                RawStreamingChoice::ToolCallDelta {
481                    id,
482                    internal_call_id,
483                    content,
484                } => Poll::Ready(Some(Ok(StreamedAssistantContent::ToolCallDelta {
485                    id,
486                    internal_call_id,
487                    content,
488                }))),
489                RawStreamingChoice::Reasoning { id, content } => {
490                    let reasoning = Reasoning {
491                        id,
492                        content: vec![content],
493                    };
494                    stream.text_item_index = None;
495                    // Full reasoning block supersedes any delta accumulation
496                    stream.reasoning_item_index = None;
497                    stream
498                        .assistant_items
499                        .push(AssistantContent::Reasoning(reasoning.clone()));
500                    Poll::Ready(Some(Ok(StreamedAssistantContent::Reasoning(reasoning))))
501                }
502                RawStreamingChoice::ReasoningDelta { id, reasoning } => {
503                    stream.text_item_index = None;
504                    stream.append_reasoning_chunk(&id, &reasoning);
505                    Poll::Ready(Some(Ok(StreamedAssistantContent::ReasoningDelta {
506                        id,
507                        reasoning,
508                    })))
509                }
510                RawStreamingChoice::ToolCall(raw_tool_call) => {
511                    let internal_call_id = raw_tool_call.internal_call_id.clone();
512                    let tool_call: ToolCall = raw_tool_call.into();
513                    stream.text_item_index = None;
514                    stream.reasoning_item_index = None;
515                    stream
516                        .assistant_items
517                        .push(AssistantContent::ToolCall(tool_call.clone()));
518                    Poll::Ready(Some(Ok(StreamedAssistantContent::ToolCall {
519                        tool_call,
520                        internal_call_id,
521                    })))
522                }
523                RawStreamingChoice::FinalResponse(response) => {
524                    if stream
525                        .final_response_yielded
526                        .load(std::sync::atomic::Ordering::SeqCst)
527                    {
528                        stream.poll_next_unpin(cx)
529                    } else {
530                        // Set the final response field and return the next item in the stream
531                        stream.response = Some(response.clone());
532                        stream
533                            .final_response_yielded
534                            .store(true, std::sync::atomic::Ordering::SeqCst);
535                        let final_response = StreamedAssistantContent::final_response(response);
536                        Poll::Ready(Some(Ok(final_response)))
537                    }
538                }
539                RawStreamingChoice::MessageId(id) => {
540                    stream.message_id = Some(id);
541                    stream.poll_next_unpin(cx)
542                }
543                RawStreamingChoice::Unknown(value) => {
544                    // Pass an unmodeled provider item straight through to the
545                    // consumer; it is intentionally not pushed into
546                    // `assistant_items` (no `AssistantContent::Unknown` exists).
547                    Poll::Ready(Some(Ok(StreamedAssistantContent::Unknown(value))))
548                }
549            },
550        }
551    }
552}
553
554// Test module
555#[cfg(test)]
556mod tests {
557    use std::time::Duration;
558
559    use super::*;
560    use crate::test_utils::MockResponse;
561    use async_stream::stream;
562    use tokio::time::sleep;
563
564    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
565    fn to_stream_result(
566        stream: impl futures::Stream<Item = Result<RawStreamingChoice<MockResponse>, CompletionError>>
567        + Send
568        + 'static,
569    ) -> StreamingResult<MockResponse> {
570        Box::pin(stream)
571    }
572
573    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
574    fn to_stream_result(
575        stream: impl futures::Stream<Item = Result<RawStreamingChoice<MockResponse>, CompletionError>>
576        + 'static,
577    ) -> StreamingResult<MockResponse> {
578        Box::pin(stream)
579    }
580
581    fn create_mock_stream() -> StreamingCompletionResponse<MockResponse> {
582        let stream = stream! {
583            yield Ok(RawStreamingChoice::Message("hello 1".to_string()));
584            sleep(Duration::from_millis(100)).await;
585            yield Ok(RawStreamingChoice::Message("hello 2".to_string()));
586            sleep(Duration::from_millis(100)).await;
587            yield Ok(RawStreamingChoice::Message("hello 3".to_string()));
588            sleep(Duration::from_millis(100)).await;
589            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(15)));
590        };
591
592        StreamingCompletionResponse::stream(to_stream_result(stream))
593    }
594
595    fn create_reasoning_stream() -> StreamingCompletionResponse<MockResponse> {
596        let stream = stream! {
597            yield Ok(RawStreamingChoice::Reasoning {
598                id: Some("rs_1".to_string()),
599                content: ReasoningContent::Text {
600                    text: "step one".to_string(),
601                    signature: Some("sig_1".to_string()),
602                },
603            });
604            yield Ok(RawStreamingChoice::Message("final answer".to_string()));
605            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(5)));
606        };
607
608        StreamingCompletionResponse::stream(to_stream_result(stream))
609    }
610
611    fn create_reasoning_only_stream() -> StreamingCompletionResponse<MockResponse> {
612        let stream = stream! {
613            yield Ok(RawStreamingChoice::Reasoning {
614                id: Some("rs_only".to_string()),
615                content: ReasoningContent::Summary("hidden summary".to_string()),
616            });
617            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(2)));
618        };
619
620        StreamingCompletionResponse::stream(to_stream_result(stream))
621    }
622
623    fn create_interleaved_stream() -> StreamingCompletionResponse<MockResponse> {
624        let stream = stream! {
625            yield Ok(RawStreamingChoice::Reasoning {
626                id: Some("rs_interleaved".to_string()),
627                content: ReasoningContent::Text {
628                    text: "chain-of-thought".to_string(),
629                    signature: None,
630                },
631            });
632            yield Ok(RawStreamingChoice::Message("final-text".to_string()));
633            yield Ok(RawStreamingChoice::ToolCall(
634                RawStreamingToolCall::new(
635                    "tool_1".to_string(),
636                    "mock_tool".to_string(),
637                    serde_json::json!({"arg": 1}),
638                ),
639            ));
640            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(3)));
641        };
642
643        StreamingCompletionResponse::stream(to_stream_result(stream))
644    }
645
646    fn create_text_tool_text_stream() -> StreamingCompletionResponse<MockResponse> {
647        let stream = stream! {
648            yield Ok(RawStreamingChoice::Message("first".to_string()));
649            yield Ok(RawStreamingChoice::ToolCall(
650                RawStreamingToolCall::new(
651                    "tool_split".to_string(),
652                    "mock_tool".to_string(),
653                    serde_json::json!({"arg": "x"}),
654                ),
655            ));
656            yield Ok(RawStreamingChoice::Message("second".to_string()));
657            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(3)));
658        };
659
660        StreamingCompletionResponse::stream(to_stream_result(stream))
661    }
662
663    fn create_text_metadata_stream() -> StreamingCompletionResponse<MockResponse> {
664        let stream = stream! {
665            yield Ok(RawStreamingChoice::TextStart {
666                additional_params: None,
667            });
668            yield Ok(RawStreamingChoice::Message("first".to_string()));
669            yield Ok(RawStreamingChoice::TextAdditionalParams(serde_json::json!({
670                "citations": [{
671                    "type": "char_location",
672                    "cited_text": "First citation.",
673                    "document_index": 0,
674                    "start_char_index": 0,
675                    "end_char_index": 15
676                }]
677            })));
678            yield Ok(RawStreamingChoice::TextAdditionalParams(serde_json::json!({
679                "citations": [{
680                    "type": "char_location",
681                    "cited_text": "Second citation.",
682                    "document_index": 0,
683                    "start_char_index": 16,
684                    "end_char_index": 32
685                }]
686            })));
687            yield Ok(RawStreamingChoice::TextStart {
688                additional_params: Some(serde_json::json!({
689                    "block": 2
690                })),
691            });
692            yield Ok(RawStreamingChoice::Message("second".to_string()));
693            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(3)));
694        };
695
696        StreamingCompletionResponse::stream(to_stream_result(stream))
697    }
698
699    #[tokio::test]
700    async fn into_completion_response_derives_usage_from_final_response() {
701        let mut stream = create_mock_stream();
702
703        // Drain the stream so the final response (and its usage) is captured.
704        while stream.next().await.is_some() {}
705
706        // usage() surfaces the final response's token usage...
707        assert_eq!(stream.usage().total_tokens, 15);
708
709        // ...and the From conversion carries it instead of a zero sentinel.
710        let response: CompletionResponse<Option<MockResponse>> = stream.into();
711        assert_eq!(response.usage.total_tokens, 15);
712    }
713
714    #[tokio::test]
715    async fn usage_is_zero_sentinel_before_final_response() {
716        // A stream that never yields a FinalResponse reports the zero sentinel.
717        let stream = StreamingCompletionResponse::stream(to_stream_result(stream! {
718            yield Ok(RawStreamingChoice::Message("no final response".to_string()));
719        }));
720        assert_eq!(stream.usage().total_tokens, 0);
721    }
722
723    #[tokio::test]
724    async fn test_stream_cancellation() {
725        let mut stream = create_mock_stream();
726
727        println!("Response: ");
728        let mut chunk_count = 0;
729        while let Some(chunk) = stream.next().await {
730            match chunk {
731                Ok(StreamedAssistantContent::Text(text)) => {
732                    print!("{}", text.text);
733                    std::io::Write::flush(&mut std::io::stdout()).unwrap();
734                    chunk_count += 1;
735                }
736                Ok(StreamedAssistantContent::ToolCall {
737                    tool_call,
738                    internal_call_id,
739                }) => {
740                    println!("\nTool Call: {tool_call:?}, internal_call_id={internal_call_id:?}");
741                    chunk_count += 1;
742                }
743                Ok(StreamedAssistantContent::ToolCallDelta {
744                    id,
745                    internal_call_id,
746                    content,
747                }) => {
748                    println!(
749                        "\nTool Call delta: id={id:?}, internal_call_id={internal_call_id:?}, content={content:?}"
750                    );
751                    chunk_count += 1;
752                }
753                Ok(StreamedAssistantContent::Final(res)) => {
754                    println!("\nFinal response: {res:?}");
755                }
756                Ok(StreamedAssistantContent::Reasoning(reasoning)) => {
757                    let reasoning = reasoning.display_text();
758                    print!("{reasoning}");
759                    std::io::Write::flush(&mut std::io::stdout()).unwrap();
760                }
761                Ok(StreamedAssistantContent::ReasoningDelta { reasoning, .. }) => {
762                    println!("Reasoning delta: {reasoning}");
763                    chunk_count += 1;
764                }
765                Ok(StreamedAssistantContent::Unknown(value)) => {
766                    println!("\nUnknown item: {value:?}");
767                    chunk_count += 1;
768                }
769                Err(e) => {
770                    eprintln!("Error: {e:?}");
771                    break;
772                }
773            }
774
775            if chunk_count >= 2 {
776                println!("\nCancelling stream...");
777                stream.cancel();
778                println!("Stream cancelled.");
779                break;
780            }
781        }
782
783        let next_chunk = stream.next().await;
784        assert!(
785            next_chunk.is_none(),
786            "Expected no further chunks after cancellation, got {next_chunk:?}"
787        );
788    }
789
790    #[tokio::test]
791    async fn test_stream_pause_resume() {
792        let stream = create_mock_stream();
793
794        // Test pause
795        stream.pause();
796        assert!(stream.is_paused());
797
798        // Test resume
799        stream.resume();
800        assert!(!stream.is_paused());
801    }
802
803    #[tokio::test]
804    async fn test_stream_aggregates_reasoning_content() {
805        let mut stream = create_reasoning_stream();
806        while stream.next().await.is_some() {}
807
808        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
809
810        assert!(choice_items.iter().any(|item| matches!(
811            item,
812            AssistantContent::Reasoning(Reasoning {
813                id: Some(id),
814                content
815            }) if id == "rs_1"
816                && matches!(
817                    content.first(),
818                    Some(ReasoningContent::Text {
819                        text,
820                        signature: Some(signature)
821                    }) if text == "step one" && signature == "sig_1"
822                )
823        )));
824    }
825
826    #[tokio::test]
827    async fn test_stream_reasoning_only_does_not_inject_empty_text() {
828        let mut stream = create_reasoning_only_stream();
829        while stream.next().await.is_some() {}
830
831        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
832        assert_eq!(choice_items.len(), 1);
833        assert!(matches!(
834            choice_items.first(),
835            Some(AssistantContent::Reasoning(Reasoning { id: Some(id), .. })) if id == "rs_only"
836        ));
837    }
838
839    #[tokio::test]
840    async fn test_stream_aggregates_assistant_items_in_arrival_order() {
841        let mut stream = create_interleaved_stream();
842        while stream.next().await.is_some() {}
843
844        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
845        assert_eq!(choice_items.len(), 3);
846        assert!(matches!(
847            choice_items.first(),
848            Some(AssistantContent::Reasoning(Reasoning { id: Some(id), .. })) if id == "rs_interleaved"
849        ));
850        assert!(matches!(
851            choice_items.get(1),
852            Some(AssistantContent::Text(Text { text, .. })) if text == "final-text"
853        ));
854        assert!(matches!(
855            choice_items.get(2),
856            Some(AssistantContent::ToolCall(ToolCall { id, .. })) if id == "tool_1"
857        ));
858    }
859
860    #[tokio::test]
861    async fn unknown_choice_reaches_consumer_but_not_aggregated_choice() {
862        let unknown = serde_json::json!({
863            "type": "web_search_call",
864            "id": "ws_1",
865            "status": "completed",
866        });
867        let yielded = unknown.clone();
868        let stream = stream! {
869            yield Ok(RawStreamingChoice::Unknown(yielded));
870            yield Ok(RawStreamingChoice::Message("done".to_string()));
871            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(1)));
872        };
873        let mut stream = StreamingCompletionResponse::stream(to_stream_result(stream));
874
875        let mut consumer_unknown = None;
876        let mut consumer_text = String::new();
877        while let Some(item) = stream.next().await {
878            match item.expect("stream item should be Ok") {
879                StreamedAssistantContent::Unknown(value) => consumer_unknown = Some(value),
880                StreamedAssistantContent::Text(text) => consumer_text.push_str(&text.text),
881                _ => {}
882            }
883        }
884
885        // The consumer receives the unmodeled item verbatim ...
886        assert_eq!(consumer_unknown.as_ref(), Some(&unknown));
887        assert_eq!(consumer_text, "done");
888
889        // ... but it is structurally absent from the aggregated assistant choice
890        // (the sole source of persisted history): only the text item remains.
891        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
892        assert_eq!(choice_items.len(), 1);
893        assert!(matches!(
894            choice_items.first(),
895            Some(AssistantContent::Text(Text { text, .. })) if text == "done"
896        ));
897    }
898
899    #[tokio::test]
900    async fn test_stream_keeps_non_contiguous_text_chunks_split_by_tool_call() {
901        let mut stream = create_text_tool_text_stream();
902        while stream.next().await.is_some() {}
903
904        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
905        assert_eq!(choice_items.len(), 3);
906        assert!(matches!(
907            choice_items.first(),
908            Some(AssistantContent::Text(Text { text, .. })) if text == "first"
909        ));
910        assert!(matches!(
911            choice_items.get(1),
912            Some(AssistantContent::ToolCall(ToolCall { id, .. })) if id == "tool_split"
913        ));
914        assert!(matches!(
915            choice_items.get(2),
916            Some(AssistantContent::Text(Text { text, .. })) if text == "second"
917        ));
918    }
919
920    #[tokio::test]
921    async fn test_stream_preserves_text_additional_params() {
922        let mut stream = create_text_metadata_stream();
923        while stream.next().await.is_some() {}
924
925        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
926        assert_eq!(choice_items.len(), 2);
927
928        let Some(AssistantContent::Text(Text {
929            text,
930            additional_params: Some(additional_params),
931        })) = choice_items.first()
932        else {
933            panic!("expected first text item with metadata");
934        };
935        assert_eq!(text, "first");
936        assert_eq!(
937            additional_params["citations"]
938                .as_array()
939                .expect("citations should be an array")
940                .len(),
941            2
942        );
943
944        let Some(AssistantContent::Text(Text {
945            text,
946            additional_params: Some(additional_params),
947        })) = choice_items.get(1)
948        else {
949            panic!("expected second text item with metadata");
950        };
951        assert_eq!(text, "second");
952        assert_eq!(additional_params["block"], 2);
953    }
954}
955
956/// Describes responses from a streamed provider response which is either text, a tool call or a final usage response.
957#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
958#[serde(untagged)]
959pub enum StreamedAssistantContent<R> {
960    /// Text delta emitted by the assistant.
961    Text(Text),
962    /// Complete tool call emitted by the assistant.
963    ToolCall {
964        tool_call: ToolCall,
965        /// Rig-generated unique identifier for this tool call.
966        /// Use this to correlate with ToolCallDelta events.
967        internal_call_id: String,
968    },
969    /// Partial tool call data emitted by the assistant.
970    ToolCallDelta {
971        /// Provider-supplied tool call ID.
972        id: String,
973        /// Rig-generated unique identifier for this tool call.
974        internal_call_id: String,
975        content: ToolCallDeltaContent,
976    },
977    /// Complete reasoning block emitted by the assistant.
978    Reasoning(Reasoning),
979    /// Partial reasoning text emitted by the assistant.
980    ReasoningDelta {
981        /// Provider-supplied reasoning block ID, when present.
982        id: Option<String>,
983        /// Partial reasoning text.
984        reasoning: String,
985    },
986    /// Final provider response object, if yielded by the provider stream.
987    Final(R),
988    /// A provider-native output item rig does not model, preserved verbatim —
989    /// e.g. an OpenAI Responses hosted-tool result (`web_search_call`,
990    /// `file_search_call`, `computer_call`, `code_interpreter_call`). It is
991    /// yielded to the consumer for inspection/forwarding but is not added to the
992    /// accumulated assistant message or persisted history. Kept last because the
993    /// enum is `#[serde(untagged)]` and a raw [`Value`](serde_json::Value)
994    /// matches anything, so earlier (typed) variants must be tried first.
995    Unknown(serde_json::Value),
996}
997
998impl<R> StreamedAssistantContent<R>
999where
1000    R: Clone + Unpin,
1001{
1002    /// Create a text stream item.
1003    pub fn text(text: &str) -> Self {
1004        Self::Text(Text::new(text.to_string()))
1005    }
1006
1007    /// Create a final response stream item.
1008    pub fn final_response(res: R) -> Self {
1009        Self::Final(res)
1010    }
1011}
1012
1013/// Streamed user content. This content is primarily used to represent tool results from tool calls made during a multi-turn/step agent prompt.
1014#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1015#[serde(untagged)]
1016pub enum StreamedUserContent {
1017    /// Tool result emitted during a multi-turn streaming agent loop.
1018    ToolResult {
1019        tool_result: ToolResult,
1020        /// Rig-generated unique identifier for the tool call this result
1021        /// belongs to. Use this to correlate with the originating
1022        /// [`StreamedAssistantContent::ToolCall::internal_call_id`].
1023        internal_call_id: String,
1024    },
1025}
1026
1027impl StreamedUserContent {
1028    /// Create a streamed tool result correlated to an internal tool call ID.
1029    pub fn tool_result(tool_result: ToolResult, internal_call_id: String) -> Self {
1030        Self::ToolResult {
1031            tool_result,
1032            internal_call_id,
1033        }
1034    }
1035}