Skip to main content

llm_universal/
stream.rs

1//! Streaming: a small Server-Sent Events parser and a driver that turns provider events
2//! into [`StreamEvent`]s, ending with the full [`ChatResponse`].
3
4use std::collections::VecDeque;
5use std::pin::Pin;
6
7use futures_core::Stream;
8
9use crate::http::MAX_RESPONSE_BYTES;
10use crate::{ChatResponse, LlmError, Result};
11
12/// One SSE event larger than this (without a blank line) is treated as an attack or a bug.
13const MAX_EVENT_BYTES: usize = 4 * 1024 * 1024;
14
15/// What a stream yields. The last item is always [`StreamEvent::Done`] (or an error).
16#[derive(Debug, Clone, PartialEq)]
17#[non_exhaustive]
18pub enum StreamEvent {
19    /// More answer text.
20    TextDelta(String),
21    /// More reasoning text (when the provider shows it).
22    ReasoningDelta(String),
23    /// The model started a tool call.
24    ToolCallStart { id: String, name: String },
25    /// More JSON of a tool call's arguments.
26    ToolCallDelta { id: String, arguments: String },
27    /// The complete response, the same as a non-streaming call would return.
28    Done(ChatResponse),
29}
30
31/// A stream of [`StreamEvent`]s. Read it with `StreamExt::next` (re-exported by this crate).
32pub type ChatStream = Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>;
33
34/// Per-provider state: turns SSE events into [`StreamEvent`]s and builds the final response.
35pub(crate) trait Accumulator: Send + 'static {
36    fn on_event(&mut self, event: &str, data: &str) -> Result<Vec<StreamEvent>>;
37    fn finish(&mut self) -> Result<ChatResponse>;
38}
39
40/// Incremental SSE parser (`event:` / `data:` lines, events separated by a blank line).
41/// Line endings may be `\n`, `\r\n` or a lone `\r`, as the SSE spec allows.
42#[derive(Default)]
43pub(crate) struct SseParser {
44    buf: Vec<u8>,
45    /// Where the search for the next blank line resumes, so bytes are scanned once.
46    scanned: usize,
47    /// The previous chunk ended with `\r`; a leading `\n` in the next one belongs to it.
48    pending_cr: bool,
49}
50
51impl SseParser {
52    /// Feeds bytes and returns every complete `(event, data)` pair.
53    pub(crate) fn push(&mut self, bytes: &[u8]) -> Result<Vec<(String, String)>> {
54        for &b in bytes {
55            match b {
56                b'\r' => {
57                    self.buf.push(b'\n');
58                    self.pending_cr = true;
59                }
60                b'\n' if self.pending_cr => self.pending_cr = false,
61                _ => {
62                    self.buf.push(b);
63                    self.pending_cr = false;
64                }
65            }
66        }
67
68        let mut events = Vec::new();
69        let mut consumed = 0;
70        let mut i = self.scanned.max(consumed + 1);
71        while i < self.buf.len() {
72            if self.buf[i] == b'\n' && self.buf[i - 1] == b'\n' {
73                if let Some(event) = parse_block(&String::from_utf8_lossy(&self.buf[consumed..=i]))
74                {
75                    events.push(event);
76                }
77                consumed = i + 1;
78                i = consumed + 1;
79            } else {
80                i += 1;
81            }
82        }
83        // One drain per push, not per event.
84        self.buf.drain(..consumed);
85        self.scanned = self.buf.len().max(1);
86
87        if self.buf.len() > MAX_EVENT_BYTES {
88            return Err(LlmError::InvalidResponse(format!(
89                "stream event exceeds {MAX_EVENT_BYTES} bytes"
90            )));
91        }
92        Ok(events)
93    }
94
95    /// Whatever is left when the connection closes without a final blank line.
96    pub(crate) fn flush(&mut self) -> Option<(String, String)> {
97        self.scanned = 0;
98        let rest = String::from_utf8_lossy(&std::mem::take(&mut self.buf)).into_owned();
99        parse_block(&rest)
100    }
101}
102
103/// The connection closed before the provider's final event: the answer may be cut off.
104pub(crate) fn ended_early() -> LlmError {
105    LlmError::InvalidResponse(
106        "stream ended before the final event; the response may be incomplete".into(),
107    )
108}
109
110fn parse_block(block: &str) -> Option<(String, String)> {
111    let mut event = String::new();
112    let mut data: Vec<&str> = Vec::new();
113    for line in block.lines() {
114        if let Some(value) = line.strip_prefix("event:") {
115            event = value.trim().to_string();
116        } else if let Some(value) = line.strip_prefix("data:") {
117            data.push(value.strip_prefix(' ').unwrap_or(value));
118        }
119        // Lines starting with ':' are comments (keep-alives); other fields are ignored.
120    }
121    (!data.is_empty()).then(|| (event, data.join("\n")))
122}
123
124struct State {
125    response: reqwest::Response,
126    parser: SseParser,
127    acc: Box<dyn Accumulator>,
128    queue: VecDeque<Result<StreamEvent>>,
129    read: usize,
130    finished: bool,
131}
132
133impl State {
134    fn handle(&mut self, events: Vec<(String, String)>) {
135        for (event, data) in events {
136            match self.acc.on_event(&event, &data) {
137                Ok(out) => self.queue.extend(out.into_iter().map(Ok)),
138                Err(e) => {
139                    self.queue.push_back(Err(e));
140                    self.finished = true;
141                    return;
142                }
143            }
144        }
145    }
146
147    fn fail(&mut self, error: LlmError) {
148        self.queue.push_back(Err(error));
149        self.finished = true;
150    }
151}
152
153/// Turns an SSE response into a [`ChatStream`].
154pub(crate) fn drive(response: reqwest::Response, acc: Box<dyn Accumulator>) -> ChatStream {
155    let state = State {
156        response,
157        parser: SseParser::default(),
158        acc,
159        queue: VecDeque::new(),
160        read: 0,
161        finished: false,
162    };
163    Box::pin(futures_util::stream::unfold(
164        state,
165        |mut state| async move {
166            loop {
167                if let Some(item) = state.queue.pop_front() {
168                    return Some((item, state));
169                }
170                if state.finished {
171                    return None;
172                }
173                match state.response.chunk().await {
174                    Ok(Some(bytes)) => {
175                        state.read += bytes.len();
176                        if state.read > MAX_RESPONSE_BYTES {
177                            state.fail(LlmError::InvalidResponse(format!(
178                                "stream exceeds {MAX_RESPONSE_BYTES} bytes"
179                            )));
180                            continue;
181                        }
182                        match state.parser.push(&bytes) {
183                            Ok(events) => state.handle(events),
184                            Err(e) => state.fail(e),
185                        }
186                    }
187                    Ok(None) => {
188                        if let Some(last) = state.parser.flush() {
189                            state.handle(vec![last]);
190                        }
191                        if !state.finished {
192                            let done = state.acc.finish().map(StreamEvent::Done);
193                            state.queue.push_back(done);
194                            state.finished = true;
195                        }
196                    }
197                    Err(e) => state.fail(LlmError::from_reqwest(e)),
198                }
199            }
200        },
201    ))
202}
203
204/// Parses a JSON SSE payload, or reports it.
205pub(crate) fn parse_data(data: &str) -> Result<serde_json::Value> {
206    serde_json::from_str(data).map_err(|source| LlmError::Deserialize {
207        source,
208        content: crate::error::truncate(data),
209    })
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn parses_events_split_across_chunks() {
218        let mut p = SseParser::default();
219        assert!(p.push(b"event: ping\ndata: {\"a\"").unwrap().is_empty());
220        let events = p
221            .push(b":1}\r\n\r\n: keep-alive\n\ndata: [DONE]\n\n")
222            .unwrap();
223        assert_eq!(
224            events,
225            vec![
226                ("ping".to_string(), "{\"a\":1}".to_string()),
227                (String::new(), "[DONE]".to_string())
228            ]
229        );
230    }
231
232    #[test]
233    fn multi_line_data_is_joined() {
234        let mut p = SseParser::default();
235        let events = p.push(b"data: line1\ndata: line2\n\n").unwrap();
236        assert_eq!(events[0].1, "line1\nline2");
237    }
238
239    #[test]
240    fn lone_cr_and_split_crlf_line_endings() {
241        let mut p = SseParser::default();
242        assert!(p.push(b"data: a\r").unwrap().is_empty());
243        let events = p.push(b"\n\r\ndata: b\r\r").unwrap();
244        assert_eq!(
245            events,
246            vec![
247                (String::new(), "a".to_string()),
248                (String::new(), "b".to_string())
249            ]
250        );
251    }
252
253    #[test]
254    fn many_events_in_one_chunk() {
255        let mut p = SseParser::default();
256        let chunk: Vec<u8> = (0..1000)
257            .flat_map(|i| format!("data: {i}\n\n").into_bytes())
258            .collect();
259        let events = p.push(&chunk).unwrap();
260        assert_eq!(events.len(), 1000);
261        assert_eq!(events[999].1, "999");
262    }
263
264    #[test]
265    fn oversized_event_is_rejected() {
266        let mut p = SseParser::default();
267        let big = vec![b'x'; MAX_EVENT_BYTES + 1];
268        assert!(p.push(&big).is_err());
269    }
270}