use std::{collections::VecDeque, pin::Pin, sync::Arc};
use futures::{Stream, StreamExt, stream};
use tracing::trace;
use crate::{
client::{
error::{ZaiError, ZaiResult},
http::HttpClient,
},
model::{
chat_base_response::ToolCallMessage, chat_stream_response::ChatStreamResponse,
sse_parser::SseEventParser, traits::SseStreamable,
},
};
pub fn parse_chat_stream_event(event: &[u8]) -> ZaiResult<Option<ChatStreamResponse>> {
if event == b"[DONE]" {
return Ok(None);
}
serde_json::from_slice::<ChatStreamResponse>(event)
.map(Some)
.map_err(|e| ZaiError::JsonError(Arc::new(e)))
}
#[derive(Debug, Clone, Default)]
pub struct ChatStreamAccumulator {
pub content: String,
pub reasoning_content: String,
pub tool_calls: Vec<ToolCallMessage>,
}
impl ChatStreamAccumulator {
pub fn new() -> Self {
Self::default()
}
pub fn push_chunk(&mut self, chunk: &ChatStreamResponse) {
for choice in &chunk.choices {
let Some(delta) = &choice.delta else {
continue;
};
if let Some(content) = &delta.content {
self.content.push_str(content);
}
if let Some(reasoning_content) = &delta.reasoning_content {
self.reasoning_content.push_str(reasoning_content);
}
if let Some(tool_calls) = &delta.tool_calls {
merge_tool_calls(&mut self.tool_calls, tool_calls);
}
}
}
}
fn merge_tool_calls(accumulated: &mut Vec<ToolCallMessage>, deltas: &[ToolCallMessage]) {
for (index, delta) in deltas.iter().enumerate() {
if accumulated.len() <= index {
accumulated.push(delta.clone());
continue;
}
let current = &mut accumulated[index];
if delta.id.is_some() {
current.id = delta.id.clone();
}
if delta.type_.is_some() {
current.type_ = delta.type_.clone();
}
if let Some(delta_function) = &delta.function {
match &mut current.function {
Some(current_function) => {
if delta_function.name.is_some() {
current_function.name = delta_function.name.clone();
}
if let Some(arguments) = &delta_function.arguments {
current_function
.arguments
.get_or_insert_with(String::new)
.push_str(arguments);
}
},
None => current.function = Some(delta_function.clone()),
}
}
if delta.mcp.is_some() {
current.mcp = delta.mcp.clone();
}
}
}
pub trait StreamChatLikeExt: SseStreamable + HttpClient {
fn stream_for_each<'a, F, Fut>(
&'a mut self,
mut on_chunk: F,
) -> impl core::future::Future<Output = crate::ZaiResult<()>> + 'a
where
F: FnMut(ChatStreamResponse) -> Fut + 'a,
Fut: core::future::Future<Output = crate::ZaiResult<()>> + 'a,
{
async move {
let resp = self.post().await?;
let mut stream = resp.bytes_stream();
let mut parser = SseEventParser::new();
while let Some(next) = stream.next().await {
let bytes = match next {
Ok(b) => b,
Err(e) => {
return Err(crate::client::error::ZaiError::NetworkError(
std::sync::Arc::new(e),
));
},
};
for event in parser.push(&bytes) {
trace!(parser = "sse", bytes = event.len(), "SSE chunk received");
match parse_chat_stream_event(&event)? {
Some(chunk) => on_chunk(chunk).await?,
None => return Ok(()),
}
}
}
Ok(())
}
}
fn to_stream<'a>(
&'a mut self,
) -> impl core::future::Future<
Output = crate::ZaiResult<
Pin<Box<dyn Stream<Item = crate::ZaiResult<ChatStreamResponse>> + Send + 'static>>,
>,
> + 'a {
async move {
let resp = self.post().await?;
let byte_stream = resp.bytes_stream();
let s = byte_stream;
let out = stream::unfold(
(
s,
SseEventParser::new(),
VecDeque::<ZaiResult<ChatStreamResponse>>::new(),
false,
),
|(mut s, mut parser, mut pending, done)| async move {
if let Some(item) = pending.pop_front() {
return Some((item, (s, parser, pending, done)));
}
if done {
return None;
}
loop {
match s.next().await {
Some(Ok(bytes)) => {
let mut saw_done = done;
for event in parser.push(&bytes) {
trace!(parser = "sse", bytes = event.len(), "SSE chunk received");
match parse_chat_stream_event(&event) {
Ok(Some(item)) => pending.push_back(Ok(item)),
Ok(None) => {
saw_done = true;
break;
},
Err(e) => pending.push_back(Err(e)),
}
}
if let Some(item) = pending.pop_front() {
return Some((item, (s, parser, pending, saw_done)));
}
if saw_done {
return None;
}
},
Some(Err(e)) => {
return Some((
Err(crate::client::error::ZaiError::NetworkError(
std::sync::Arc::new(e),
)),
(s, parser, pending, done),
));
},
None => return None,
}
}
},
)
.boxed();
Ok(out)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
chat_base_response::{ToolCallMessage, ToolFunction},
chat_stream_response::{ChatStreamResponse, Delta, StreamChoice},
};
fn chunk_with_delta(delta: Delta) -> ChatStreamResponse {
ChatStreamResponse {
id: Some("test".to_string()),
created: Some(1),
model: Some("glm-test".to_string()),
choices: vec![StreamChoice {
index: Some(0),
delta: Some(delta),
finish_reason: None,
}],
usage: None,
}
}
#[test]
fn parse_done_event() {
assert!(parse_chat_stream_event(b"[DONE]").unwrap().is_none());
}
#[test]
fn parse_invalid_event_returns_json_error() {
let err = parse_chat_stream_event(b"{not-json").unwrap_err();
assert!(matches!(err, ZaiError::JsonError(_)));
}
#[test]
fn accumulator_appends_content_reasoning_and_tool_arguments() {
let mut acc = ChatStreamAccumulator::new();
acc.push_chunk(&chunk_with_delta(Delta {
role: Some("assistant".to_string()),
content: Some("hel".to_string()),
reasoning_content: Some("think ".to_string()),
tool_calls: Some(vec![ToolCallMessage {
id: Some("call_1".to_string()),
type_: Some("function".to_string()),
function: Some(ToolFunction {
name: Some("search".to_string()),
arguments: Some("{\"q\":\"ru".to_string()),
}),
mcp: None,
}]),
}));
acc.push_chunk(&chunk_with_delta(Delta {
role: None,
content: Some("lo".to_string()),
reasoning_content: Some("done".to_string()),
tool_calls: Some(vec![ToolCallMessage {
id: None,
type_: None,
function: Some(ToolFunction {
name: None,
arguments: Some("st\"}".to_string()),
}),
mcp: None,
}]),
}));
assert_eq!(acc.content, "hello");
assert_eq!(acc.reasoning_content, "think done");
assert_eq!(
acc.tool_calls[0]
.function
.as_ref()
.and_then(|function| function.arguments.as_deref()),
Some("{\"q\":\"rust\"}")
);
}
}