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}