use eventsource_stream::Eventsource;
use futures::stream;
use futures::StreamExt;
use reqwest::Response;
use serde_json::Value;
use crate::provider::{LLMError, LLMStream, Result};
use crate::types::LLMChunk;
pub(crate) fn sse_error_is_present(error: &Value) -> bool {
match error {
Value::Null => false,
Value::String(s) => !s.trim().is_empty(),
Value::Object(map) => !map.is_empty(),
Value::Array(items) => !items.is_empty(),
_ => true,
}
}
fn to_stream_error(err: LLMError) -> LLMError {
match err {
LLMError::Stream(msg) => LLMError::Stream(msg),
other => LLMError::Stream(other.to_string()),
}
}
pub fn llm_stream_from_sse<H>(response: Response, mut handler: H) -> LLMStream
where
H: FnMut(&str, &str) -> Result<Option<LLMChunk>> + Send + 'static,
{
llm_stream_from_sse_multi(response, move |event, data| {
Ok(handler(event, data)?.into_iter().collect())
})
}
pub fn llm_stream_from_sse_multi<H>(response: Response, mut handler: H) -> LLMStream
where
H: FnMut(&str, &str) -> Result<Vec<LLMChunk>> + Send + 'static,
{
let stream = response
.bytes_stream()
.eventsource()
.map(move |event| {
let event = event.map_err(|e| LLMError::Stream(e.to_string()))?;
handler(event.event.as_str(), event.data.as_str()).map_err(to_stream_error)
})
.flat_map(|result| {
stream::iter(match result {
Ok(chunks) if chunks.is_empty() => vec![Ok(LLMChunk::TransportActivity)],
Ok(chunks) => chunks.into_iter().map(Ok).collect::<Vec<_>>(),
Err(err) => vec![Err(err)],
})
});
Box::pin(stream)
}
pub fn llm_stream_from_sse_multi_requiring_done<H>(
response: Response,
handler: H,
protocol: &'static str,
) -> LLMStream
where
H: FnMut(&str, &str) -> Result<Vec<LLMChunk>> + Send + 'static,
{
require_done_terminal(llm_stream_from_sse_multi(response, handler), protocol)
}
fn require_done_terminal(upstream: LLMStream, protocol: &'static str) -> LLMStream {
let stream = stream::unfold(
(upstream, false),
move |(mut upstream, terminal)| async move {
if terminal {
return None;
}
match upstream.next().await {
Some(Ok(LLMChunk::Done)) => Some((Ok(LLMChunk::Done), (upstream, true))),
Some(Err(error)) => Some((Err(error), (upstream, true))),
Some(other) => Some((other, (upstream, false))),
None => Some((
Err(LLMError::Stream(format!(
"{protocol} stream ended before a protocol terminal event"
))),
(upstream, true),
)),
}
},
);
Box::pin(stream)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::anthropic::{parse_anthropic_sse_event, AnthropicStreamState};
use crate::providers::common::openai_compat::parse_openai_compat_sse_data_strict_multi;
use crate::providers::common::openai_responses::ResponsesSseParser;
use futures::StreamExt;
use serde_json::json;
#[test]
fn sse_error_is_present_distinguishes_real_errors_from_benign_markers() {
assert!(!sse_error_is_present(&Value::Null));
assert!(!sse_error_is_present(&json!("")));
assert!(!sse_error_is_present(&json!(" ")));
assert!(!sse_error_is_present(&json!({})));
assert!(!sse_error_is_present(&json!([])));
assert!(sse_error_is_present(&json!("boom")));
assert!(sse_error_is_present(
&json!({ "message": "API key invalid" })
));
assert!(sse_error_is_present(&json!(["e"])));
assert!(sse_error_is_present(&json!(42)));
assert!(sse_error_is_present(&json!(true)));
}
#[tokio::test]
async fn llm_stream_from_sse_preserves_filtered_event_as_transport_activity() {
let sse_body = concat!(
"event: token\n",
"data: hello\n",
"\n",
"event: token\n",
"data: skip\n",
"\n",
);
let response = reqwest::Response::from(
http::Response::builder()
.status(200)
.header("content-type", "text/event-stream")
.body(sse_body.to_string())
.expect("http response"),
);
let mut stream = llm_stream_from_sse(response, |event, data| {
if data == "skip" {
return Ok(None);
}
Ok(Some(LLMChunk::Token(format!("{event}:{data}"))))
});
let mut out = Vec::new();
while let Some(item) = stream.next().await {
out.push(item.expect("chunk"));
}
assert_eq!(out.len(), 2);
match &out[0] {
LLMChunk::Token(token) => assert_eq!(token, "token:hello"),
other => panic!("expected LLMChunk::Token, got {other:?}"),
}
assert!(matches!(out[1], LLMChunk::TransportActivity));
}
#[tokio::test]
async fn anthropic_ping_is_preserved_as_transport_activity() {
let response = reqwest::Response::from(
http::Response::builder()
.status(200)
.header("content-type", "text/event-stream")
.body("event: ping\ndata: {\"type\":\"ping\"}\n\n".to_string())
.expect("http response"),
);
let mut state = AnthropicStreamState::default();
let mut stream = llm_stream_from_sse(response, move |event, data| {
parse_anthropic_sse_event(&mut state, event, data)
});
let chunk = stream
.next()
.await
.expect("ping should yield a liveness marker")
.expect("ping should not fail the stream");
assert!(matches!(chunk, LLMChunk::TransportActivity));
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn openai_responses_keepalive_is_preserved_as_transport_activity() {
let response = reqwest::Response::from(
http::Response::builder()
.status(200)
.header("content-type", "text/event-stream")
.body("event: ping\ndata: keep-alive\n\n".to_string())
.expect("http response"),
);
let mut parser = ResponsesSseParser::new();
let mut stream = llm_stream_from_sse(response, move |event, data| {
parser.handle_event(event, data)
});
let chunk = stream
.next()
.await
.expect("keepalive should yield a liveness marker")
.expect("keepalive should not fail the stream");
assert!(matches!(chunk, LLMChunk::TransportActivity));
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn openai_responses_completed_frame_flattens_every_parser_chunk() {
let response = reqwest::Response::from(
http::Response::builder()
.status(200)
.header("content-type", "text/event-stream")
.body(
concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_terminal\",\"output\":[{\"id\":\"msg_terminal\",\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"terminal answer\"}]}],\"usage\":{\"input_tokens\":21,\"output_tokens\":13,\"input_tokens_details\":{\"cached_tokens\":8},\"output_tokens_details\":{\"reasoning_tokens\":5}}}}\n",
"\n",
)
.to_string(),
)
.expect("http response"),
);
let mut parser = ResponsesSseParser::new();
let mut stream = llm_stream_from_sse_multi(response, move |event, data| {
parser.handle_event_multi(event, data)
});
let mut chunks = Vec::new();
while let Some(item) = stream.next().await {
chunks.push(item.expect("stream chunk"));
}
assert_eq!(chunks.len(), 4);
assert!(matches!(&chunks[0], LLMChunk::ResponseId(id) if id == "resp_terminal"));
assert!(matches!(&chunks[1], LLMChunk::Token(text) if text == "terminal answer"));
assert!(matches!(
chunks[2],
LLMChunk::ProviderUsage {
input_tokens: Some(21),
output_tokens: Some(13),
reasoning_tokens: Some(5),
cache_creation_input_tokens: None,
cache_read_input_tokens: Some(8),
..
}
));
assert!(matches!(chunks[3], LLMChunk::Done));
}
#[tokio::test]
async fn openai_chat_frame_flattens_business_output_and_usage_before_done() {
let response = reqwest::Response::from(
http::Response::builder()
.status(200)
.header("content-type", "text/event-stream")
.body(
concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":4,\"prompt_tokens_details\":{\"cached_tokens\":3}}}\n",
"\n",
"data: [DONE]\n",
"\n",
)
.to_string(),
)
.expect("http response"),
);
let mut stream = llm_stream_from_sse_multi(response, |_event, data| {
parse_openai_compat_sse_data_strict_multi(data)
});
let mut chunks = Vec::new();
while let Some(item) = stream.next().await {
chunks.push(item.expect("stream chunk"));
}
assert_eq!(chunks.len(), 3);
assert!(matches!(&chunks[0], LLMChunk::Token(text) if text == "answer"));
assert!(matches!(
chunks[1],
LLMChunk::ProviderUsage {
input_tokens: Some(10),
output_tokens: Some(4),
cache_read_input_tokens: Some(3),
..
}
));
assert!(matches!(chunks[2], LLMChunk::Done));
}
#[tokio::test]
async fn llm_stream_from_sse_maps_handler_errors_to_stream_error() {
let sse_body = concat!("event: token\n", "data: boom\n", "\n");
let response = reqwest::Response::from(
http::Response::builder()
.status(200)
.header("content-type", "text/event-stream")
.body(sse_body.to_string())
.expect("http response"),
);
let mut stream = llm_stream_from_sse(response, |_event, _data| {
Err(LLMError::Api("boom".to_string()))
});
let Some(item) = stream.next().await else {
panic!("expected one stream item");
};
match item {
Ok(chunk) => panic!("expected error, got chunk: {chunk:?}"),
Err(LLMError::Stream(msg)) => assert!(msg.contains("API error")),
Err(other) => panic!("expected LLMError::Stream, got: {other:?}"),
}
}
#[tokio::test]
async fn required_done_turns_clean_eof_into_one_error() {
let upstream: LLMStream = Box::pin(stream::iter(vec![Ok(LLMChunk::Token(
"partial".to_string(),
))]));
let mut stream = require_done_terminal(upstream, "Responses");
assert!(matches!(
stream.next().await,
Some(Ok(LLMChunk::Token(text))) if text == "partial"
));
let error = stream
.next()
.await
.expect("premature EOF error")
.expect_err("EOF must not synthesize success");
assert!(error
.to_string()
.contains("ended before a protocol terminal"));
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn required_done_stops_after_first_success_terminal() {
let upstream: LLMStream = Box::pin(stream::iter(vec![
Ok(LLMChunk::Done),
Ok(LLMChunk::Token("after".to_string())),
]));
let mut stream = require_done_terminal(upstream, "Responses");
assert!(matches!(stream.next().await, Some(Ok(LLMChunk::Done))));
assert!(stream.next().await.is_none());
}
}