Skip to main content

zai_rs/model/
stream_ext.rs

1//! # Streaming Extensions for Chat-like Endpoints
2//!
3//! This module provides typed streaming capabilities for chat completion APIs
4//! that return Server-Sent Events (SSE) with `ChatStreamResponse` chunks.
5//!
6//! ## Features
7//!
8//! - **Callback-based API** - Simple async closure interface for processing
9//!   chunks
10//! - **Stream-based API** - Composable, testable, and reusable stream interface
11//! - **Type-safe parsing** - Automatic deserialization of SSE data chunks
12//! - **Error handling** - Comprehensive error propagation and handling
13//!
14//! ## Usage Patterns
15//!
16//! ### Callback-based Processing
17//! ```rust,ignore
18//! client.stream_for_each(|chunk| async move {
19//!     println!("Received: {:?}", chunk);
20//!     Ok(())
21//! }).await?;
22//! ```
23//!
24//! ### Stream-based Processing
25//! ```rust,ignore
26//! let mut stream = client.to_stream().await?;
27//! while let Some(result) = stream.next().await {
28//!     match result {
29//!         Ok(chunk) => println!("Chunk: {:?}", chunk),
30//!         Err(e) => tracing::error!("Error: {}", e),
31//!     }
32//! }
33//! ```
34
35use std::{collections::VecDeque, pin::Pin, sync::Arc};
36
37use futures::{Stream, StreamExt, stream};
38use tracing::trace;
39
40use crate::{
41    client::{
42        error::{ZaiError, ZaiResult},
43        http::HttpClient,
44    },
45    model::{
46        chat_base_response::ToolCallMessage, chat_stream_response::ChatStreamResponse,
47        sse_parser::SseEventParser, traits::SseStreamable,
48    },
49};
50
51/// Parse one completed chat SSE event.
52///
53/// Returns `Ok(None)` for the terminal `[DONE]` marker and a JSON error for
54/// malformed event payloads.
55pub fn parse_chat_stream_event(event: &[u8]) -> ZaiResult<Option<ChatStreamResponse>> {
56    if event == b"[DONE]" {
57        return Ok(None);
58    }
59
60    serde_json::from_slice::<ChatStreamResponse>(event)
61        .map(Some)
62        .map_err(|e| ZaiError::JsonError(Arc::new(e)))
63}
64
65/// Accumulates streaming chat deltas into convenient final buffers.
66#[derive(Debug, Clone, Default)]
67pub struct ChatStreamAccumulator {
68    pub content: String,
69    pub reasoning_content: String,
70    pub tool_calls: Vec<ToolCallMessage>,
71}
72
73impl ChatStreamAccumulator {
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    pub fn push_chunk(&mut self, chunk: &ChatStreamResponse) {
79        for choice in &chunk.choices {
80            let Some(delta) = &choice.delta else {
81                continue;
82            };
83
84            if let Some(content) = &delta.content {
85                self.content.push_str(content);
86            }
87            if let Some(reasoning_content) = &delta.reasoning_content {
88                self.reasoning_content.push_str(reasoning_content);
89            }
90            if let Some(tool_calls) = &delta.tool_calls {
91                merge_tool_calls(&mut self.tool_calls, tool_calls);
92            }
93        }
94    }
95}
96
97fn merge_tool_calls(accumulated: &mut Vec<ToolCallMessage>, deltas: &[ToolCallMessage]) {
98    for (index, delta) in deltas.iter().enumerate() {
99        if accumulated.len() <= index {
100            accumulated.push(delta.clone());
101            continue;
102        }
103
104        let current = &mut accumulated[index];
105        if delta.id.is_some() {
106            current.id = delta.id.clone();
107        }
108        if delta.type_.is_some() {
109            current.type_ = delta.type_.clone();
110        }
111        if let Some(delta_function) = &delta.function {
112            match &mut current.function {
113                Some(current_function) => {
114                    if delta_function.name.is_some() {
115                        current_function.name = delta_function.name.clone();
116                    }
117                    if let Some(arguments) = &delta_function.arguments {
118                        current_function
119                            .arguments
120                            .get_or_insert_with(String::new)
121                            .push_str(arguments);
122                    }
123                },
124                None => current.function = Some(delta_function.clone()),
125            }
126        }
127        if delta.mcp.is_some() {
128            current.mcp = delta.mcp.clone();
129        }
130    }
131}
132
133/// Streaming extension trait for chat-like endpoints.
134///
135/// This trait provides two complementary APIs for processing streaming
136/// responses:
137/// 1. **Callback-based** - Simple async closure interface
138/// 2. **Stream-based** - Composable stream interface for advanced usage
139///
140/// Both APIs handle SSE protocol parsing, JSON deserialization, and error
141/// propagation.
142pub trait StreamChatLikeExt: SseStreamable + HttpClient {
143    /// Processes streaming responses using an async callback function.
144    ///
145    /// This method provides a simple interface for handling streaming chat
146    /// responses. Each successfully parsed chunk is passed to the provided
147    /// callback function.
148    ///
149    /// ## Arguments
150    ///
151    /// * `on_chunk` - Async callback function that processes each
152    ///   `ChatStreamResponse` chunk
153    ///
154    /// ## Returns
155    ///
156    /// Result indicating success or failure of the streaming operation
157    ///
158    /// ## Example
159    ///
160    /// ```rust,ignore
161    /// client.stream_for_each(|chunk| async move {
162    ///     if let Some(content) = &chunk.choices[0].delta.content {
163    ///         print!("{}", content);
164    ///     }
165    ///     Ok(())
166    /// }).await?;
167    /// ```
168    fn stream_for_each<'a, F, Fut>(
169        &'a mut self,
170        mut on_chunk: F,
171    ) -> impl core::future::Future<Output = crate::ZaiResult<()>> + 'a
172    where
173        F: FnMut(ChatStreamResponse) -> Fut + 'a,
174        Fut: core::future::Future<Output = crate::ZaiResult<()>> + 'a,
175    {
176        async move {
177            let resp = self.post().await?;
178            let mut stream = resp.bytes_stream();
179            let mut parser = SseEventParser::new();
180
181            while let Some(next) = stream.next().await {
182                let bytes = match next {
183                    Ok(b) => b,
184                    Err(e) => {
185                        return Err(crate::client::error::ZaiError::NetworkError(
186                            std::sync::Arc::new(e),
187                        ));
188                    },
189                };
190                for event in parser.push(&bytes) {
191                    // Per-chunk SSE payload logging is noisy at high token
192                    // rates; keep it at `trace`.
193                    trace!(parser = "sse", bytes = event.len(), "SSE chunk received");
194                    match parse_chat_stream_event(&event)? {
195                        Some(chunk) => on_chunk(chunk).await?,
196                        None => return Ok(()),
197                    }
198                }
199            }
200            Ok(())
201        }
202    }
203
204    /// Converts the streaming response into a composable Stream.
205    ///
206    /// This method returns a `Stream` that yields `ChatStreamResponse` chunks,
207    /// enabling advanced stream processing operations like filtering, mapping,
208    /// and combination with other streams.
209    ///
210    /// ## Returns
211    ///
212    /// A future that resolves to a `Stream` of `Result<ChatStreamResponse>`
213    /// items
214    ///
215    /// ## Example
216    ///
217    /// ```rust,ignore
218    /// let stream = client.to_stream().await?;
219    /// let collected: Vec<_> = stream
220    ///     .filter_map(|result| result.ok())
221    ///     .collect()
222    ///     .await;
223    /// ```
224    fn to_stream<'a>(
225        &'a mut self,
226    ) -> impl core::future::Future<
227        Output = crate::ZaiResult<
228            Pin<Box<dyn Stream<Item = crate::ZaiResult<ChatStreamResponse>> + Send + 'static>>,
229        >,
230    > + 'a {
231        async move {
232            let resp = self.post().await?;
233            let byte_stream = resp.bytes_stream();
234
235            let s = byte_stream;
236
237            let out = stream::unfold(
238                (
239                    s,
240                    SseEventParser::new(),
241                    VecDeque::<ZaiResult<ChatStreamResponse>>::new(),
242                    false,
243                ),
244                |(mut s, mut parser, mut pending, done)| async move {
245                    if let Some(item) = pending.pop_front() {
246                        return Some((item, (s, parser, pending, done)));
247                    }
248                    if done {
249                        return None;
250                    }
251
252                    loop {
253                        // Need more bytes first to populate buffer
254                        match s.next().await {
255                            Some(Ok(bytes)) => {
256                                let mut saw_done = done;
257                                for event in parser.push(&bytes) {
258                                    trace!(parser = "sse", bytes = event.len(), "SSE chunk received");
259                                    match parse_chat_stream_event(&event) {
260                                        Ok(Some(item)) => pending.push_back(Ok(item)),
261                                        Ok(None) => {
262                                            saw_done = true;
263                                            break;
264                                        },
265                                        Err(e) => pending.push_back(Err(e)),
266                                    }
267                                }
268                                if let Some(item) = pending.pop_front() {
269                                    return Some((item, (s, parser, pending, saw_done)));
270                                }
271                                if saw_done {
272                                    return None;
273                                }
274                                // All lines processed but no valid
275                                // ChatStreamResponse yielded,
276                                // loop back to get more bytes
277                            },
278                            Some(Err(e)) => {
279                                return Some((
280                                    Err(crate::client::error::ZaiError::NetworkError(
281                                        std::sync::Arc::new(e),
282                                    )),
283                                    (s, parser, pending, done),
284                                ));
285                            },
286                            None => return None,
287                        }
288                    }
289                },
290            )
291            .boxed();
292
293            Ok(out)
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::model::{
302        chat_base_response::{ToolCallMessage, ToolFunction},
303        chat_stream_response::{ChatStreamResponse, Delta, StreamChoice},
304    };
305
306    fn chunk_with_delta(delta: Delta) -> ChatStreamResponse {
307        ChatStreamResponse {
308            id: Some("test".to_string()),
309            created: Some(1),
310            model: Some("glm-test".to_string()),
311            choices: vec![StreamChoice {
312                index: Some(0),
313                delta: Some(delta),
314                finish_reason: None,
315            }],
316            usage: None,
317        }
318    }
319
320    #[test]
321    fn parse_done_event() {
322        assert!(parse_chat_stream_event(b"[DONE]").unwrap().is_none());
323    }
324
325    #[test]
326    fn parse_invalid_event_returns_json_error() {
327        let err = parse_chat_stream_event(b"{not-json").unwrap_err();
328        assert!(matches!(err, ZaiError::JsonError(_)));
329    }
330
331    #[test]
332    fn accumulator_appends_content_reasoning_and_tool_arguments() {
333        let mut acc = ChatStreamAccumulator::new();
334
335        acc.push_chunk(&chunk_with_delta(Delta {
336            role: Some("assistant".to_string()),
337            content: Some("hel".to_string()),
338            reasoning_content: Some("think ".to_string()),
339            tool_calls: Some(vec![ToolCallMessage {
340                id: Some("call_1".to_string()),
341                type_: Some("function".to_string()),
342                function: Some(ToolFunction {
343                    name: Some("search".to_string()),
344                    arguments: Some("{\"q\":\"ru".to_string()),
345                }),
346                mcp: None,
347            }]),
348        }));
349
350        acc.push_chunk(&chunk_with_delta(Delta {
351            role: None,
352            content: Some("lo".to_string()),
353            reasoning_content: Some("done".to_string()),
354            tool_calls: Some(vec![ToolCallMessage {
355                id: None,
356                type_: None,
357                function: Some(ToolFunction {
358                    name: None,
359                    arguments: Some("st\"}".to_string()),
360                }),
361                mcp: None,
362            }]),
363        }));
364
365        assert_eq!(acc.content, "hello");
366        assert_eq!(acc.reasoning_content, "think done");
367        assert_eq!(
368            acc.tool_calls[0]
369                .function
370                .as_ref()
371                .and_then(|function| function.arguments.as_deref()),
372            Some("{\"q\":\"rust\"}")
373        );
374    }
375}