Skip to main content

gglib_core/sse/
decoder.rs

1//! Stateful SSE byte-stream decoder.
2//!
3//! [`SseStreamDecoder`] accumulates raw bytes from an HTTP response into a line
4//! buffer, drains complete `data:` lines, and delegates frame parsing to
5//! [`super::parser`].  Its explicit state makes it straightforward to unit-
6//! test without standing up an actual HTTP server or wrapping everything in an
7//! `async_stream` macro block.
8
9use anyhow::Result;
10use tracing::debug;
11
12use crate::LlmStreamEvent;
13
14use super::parser::{SseParseResult, parse_sse_frame};
15
16/// Stateful decoder that turns a sequence of raw SSE byte chunks into a
17/// sequence of [`LlmStreamEvent`] values.
18///
19/// # Usage
20///
21/// ```text
22/// let mut decoder = SseStreamDecoder::default();
23/// while let Some(chunk) = byte_stream.next().await {
24///     let (events, stop) = decoder.feed_bytes(&chunk);
25///     for event in events { … }
26///     if stop { break; }
27/// }
28/// if let Some(fallback) = decoder.finish() { … }
29/// ```
30#[derive(Default)]
31pub struct SseStreamDecoder {
32    buf: String,
33    /// Set to `true` once a [`LlmStreamEvent::Done`] has been yielded, so the
34    /// `[DONE]` sentinel doesn't generate a duplicate.
35    done_sent: bool,
36}
37
38impl SseStreamDecoder {
39    /// Feed one raw byte chunk into the decoder.
40    ///
41    /// Returns `(events, should_stop)`.
42    ///
43    /// - `events` — zero or more parsed [`LlmStreamEvent`] values (or stream
44    ///   errors) extracted from the bytes fed so far.
45    /// - `should_stop` — `true` when the SSE stream has reached its natural end
46    ///   (a `[DONE]` sentinel or an unrecoverable parse error).  The caller
47    ///   must not feed any further chunks once this flag is `true`.
48    pub fn feed_bytes(&mut self, bytes: &[u8]) -> (Vec<Result<LlmStreamEvent>>, bool) {
49        let text = match std::str::from_utf8(bytes) {
50            Ok(t) => t,
51            Err(e) => {
52                return (
53                    vec![Err(anyhow::anyhow!("invalid UTF-8 in LLM SSE stream: {e}"))],
54                    true,
55                );
56            }
57        };
58        self.buf.push_str(text);
59        let mut events = Vec::new();
60
61        while let Some(newline_pos) = self.buf.find('\n') {
62            let line = self.buf[..newline_pos].trim_end_matches('\r').to_owned();
63            self.buf.drain(..=newline_pos);
64
65            // Skip blank lines and SSE comment lines.
66            let Some(data) = line.strip_prefix("data: ") else {
67                continue;
68            };
69
70            match parse_sse_frame(data) {
71                Ok(SseParseResult::Done) => {
72                    if !self.done_sent {
73                        debug!(
74                            "LLM stream ended with [DONE] but no prior finish_reason \
75                             — emitting fallback Done with an unknown reason"
76                        );
77                        // Deliberately not "stop": the upstream never said the
78                        // turn finished cleanly, and claiming it did would
79                        // relabel a truncation as a complete answer.
80                        events.push(Ok(LlmStreamEvent::Done {
81                            finish_reason: None,
82                        }));
83                    }
84                    self.done_sent = true;
85                    return (events, true);
86                }
87                Ok(SseParseResult::Events(parsed_events)) => {
88                    let mut saw_terminal_error = false;
89                    for event in parsed_events {
90                        if matches!(event, LlmStreamEvent::Done { .. }) {
91                            self.done_sent = true;
92                        }
93                        if matches!(event, LlmStreamEvent::UpstreamError { .. }) {
94                            // Terminal condition: the encoder appends its own
95                            // `[DONE]` sentinel right after this event (see
96                            // `SseEncoder::encode`), and nothing meaningful
97                            // is expected to follow an inline upstream
98                            // error. Stop feeding further bytes, same as the
99                            // literal `[DONE]` sentinel case above, so a
100                            // stray fallback `Done` isn't appended by
101                            // `finish()`.
102                            saw_terminal_error = true;
103                            self.done_sent = true;
104                        }
105                        events.push(Ok(event));
106                    }
107                    if saw_terminal_error {
108                        return (events, true);
109                    }
110                }
111                Err(e) => {
112                    events.push(Err(e));
113                    return (events, true);
114                }
115            }
116        }
117
118        (events, false)
119    }
120
121    /// Emit a fallback `Done` event if the byte stream ended without one.
122    ///
123    /// Call this once after the upstream byte stream is fully exhausted.
124    /// Returns `None` if a `Done` was already yielded by [`Self::feed_bytes`].
125    #[must_use]
126    pub fn finish(self) -> Option<LlmStreamEvent> {
127        if self.done_sent {
128            None
129        } else {
130            debug!(
131                "LLM byte-stream ended without [DONE] sentinel — emitting fallback Done \
132                 with an unknown reason"
133            );
134            // A byte stream that simply stopped is the strongest case for not
135            // fabricating: nothing upstream ever claimed the turn was over.
136            Some(LlmStreamEvent::Done {
137                finish_reason: None,
138            })
139        }
140    }
141}
142
143// =============================================================================
144// Tests
145// =============================================================================
146
147#[cfg(test)]
148mod tests {
149    use anyhow::Result;
150
151    use super::SseStreamDecoder;
152    use crate::LlmStreamEvent;
153
154    fn text_delta_frame(text: &str) -> String {
155        let json = serde_json::json!({
156            "choices": [{
157                "delta": { "content": text },
158                "finish_reason": null
159            }]
160        });
161        format!("data: {json}\n")
162    }
163
164    fn done_frame() -> &'static str {
165        "data: [DONE]\n"
166    }
167
168    fn finish_reason_frame() -> String {
169        let json = serde_json::json!({
170            "choices": [{
171                "delta": {},
172                "finish_reason": "stop"
173            }]
174        });
175        format!("data: {json}\n")
176    }
177
178    // ---- helpers ------------------------------------------------------------
179
180    fn collect_all(decoder: &mut SseStreamDecoder, input: &str) -> (Vec<LlmStreamEvent>, bool) {
181        let (raw, stop) = decoder.feed_bytes(input.as_bytes());
182        let events: Vec<_> = raw.into_iter().map(Result::unwrap).collect();
183        (events, stop)
184    }
185
186    // ---- tests --------------------------------------------------------------
187
188    #[test]
189    fn text_delta_is_emitted() {
190        let mut dec = SseStreamDecoder::default();
191        let (events, stop) = collect_all(&mut dec, &text_delta_frame("hello"));
192        assert!(!stop);
193        assert!(
194            events
195                .iter()
196                .any(|e| matches!(e, LlmStreamEvent::TextDelta { content } if content == "hello"))
197        );
198    }
199
200    #[test]
201    fn done_sentinel_signals_stop_and_emits_fallback() {
202        let mut dec = SseStreamDecoder::default();
203        let (events, stop) = collect_all(&mut dec, done_frame());
204        assert!(stop, "decoder should signal stop on [DONE]");
205        assert!(
206            events
207                .iter()
208                .any(|e| matches!(e, LlmStreamEvent::Done { .. })),
209            "fallback Done should be emitted when no prior finish_reason"
210        );
211        assert!(
212            dec.finish().is_none(),
213            "finish() must return None after a [DONE] sentinel — done_sent must be set"
214        );
215    }
216
217    /// The relabelling this fix exists to stop. A `[DONE]` arriving with no
218    /// prior finish chunk means the upstream never said how the turn ended;
219    /// synthesising `"stop"` there reported a truncation as a clean answer,
220    /// and made `finish_reason == "length"` unusable as a truncation signal
221    /// because the abnormal cases were hiding inside `"stop"`.
222    #[test]
223    fn a_synthesised_done_reports_an_unknown_reason_not_stop() {
224        let mut dec = SseStreamDecoder::default();
225        let (events, _) = collect_all(&mut dec, done_frame());
226        let done = events
227            .iter()
228            .find_map(|e| match e {
229                LlmStreamEvent::Done { finish_reason } => Some(finish_reason),
230                _ => None,
231            })
232            .expect("a fallback Done is still emitted");
233        assert_eq!(*done, None, "must not claim the turn stopped cleanly");
234    }
235
236    #[test]
237    fn a_byte_stream_that_just_stops_reports_an_unknown_reason() {
238        let mut dec = SseStreamDecoder::default();
239        let _ = collect_all(&mut dec, &text_delta_frame("partial"));
240        match dec.finish() {
241            Some(LlmStreamEvent::Done { finish_reason }) => {
242                assert_eq!(finish_reason, None);
243            }
244            other => panic!("expected a fallback Done, got {other:?}"),
245        }
246    }
247
248    /// A reason the upstream actually reported is passed through untouched —
249    /// the fix must not lose real information while refusing to invent it.
250    #[test]
251    fn a_reported_finish_reason_survives_intact() {
252        let mut dec = SseStreamDecoder::default();
253        let (events, _) = collect_all(&mut dec, &finish_reason_frame());
254        let done = events
255            .iter()
256            .find_map(|e| match e {
257                LlmStreamEvent::Done { finish_reason } => Some(finish_reason),
258                _ => None,
259            })
260            .expect("Done from the reported finish_reason");
261        assert!(done.is_some(), "a real reason must not be discarded");
262    }
263
264    #[test]
265    fn finish_reason_then_done_no_duplicate_done() {
266        let mut dec = SseStreamDecoder::default();
267        let input = format!("{}{}", finish_reason_frame(), done_frame());
268        let (events, stop) = collect_all(&mut dec, &input);
269        assert!(stop);
270        let done_count = events
271            .iter()
272            .filter(|e| matches!(e, LlmStreamEvent::Done { .. }))
273            .count();
274        assert_eq!(done_count, 1, "exactly one Done should be emitted");
275    }
276
277    #[test]
278    fn finish_emits_fallback_when_stream_ends_without_done() {
279        let mut dec = SseStreamDecoder::default();
280        let _ = collect_all(&mut dec, &text_delta_frame("partial"));
281        let fallback = dec.finish();
282        assert!(
283            fallback.is_some(),
284            "finish() should return a fallback Done when stream ends without one"
285        );
286    }
287
288    #[test]
289    fn finish_returns_none_when_done_already_sent() {
290        let mut dec = SseStreamDecoder::default();
291        let _ = collect_all(&mut dec, &finish_reason_frame());
292        assert!(
293            dec.finish().is_none(),
294            "finish() must not emit a second Done"
295        );
296    }
297
298    #[test]
299    fn inline_error_frame_signals_stop_and_suppresses_fallback_done() {
300        let mut dec = SseStreamDecoder::default();
301        let frame = format!(
302            "data: {}\n",
303            serde_json::json!({ "error": { "message": "boom" } })
304        );
305        let (events, stop) = collect_all(&mut dec, &frame);
306        assert!(stop, "inline error frame should signal stop");
307        assert_eq!(events.len(), 1);
308        assert!(matches!(&events[0], LlmStreamEvent::UpstreamError { .. }));
309        assert!(
310            dec.finish().is_none(),
311            "finish() must not append a fallback Done after an inline error"
312        );
313    }
314
315    #[test]
316    fn partial_line_buffered_until_newline_arrives() {
317        let mut dec = SseStreamDecoder::default();
318        let full_frame = text_delta_frame("world");
319
320        let mid = full_frame.len() / 2;
321        let (first_events, stop1) = collect_all(&mut dec, &full_frame[..mid]);
322        assert!(!stop1);
323        assert!(first_events.is_empty(), "no complete line yet");
324
325        let (second_events, stop2) = collect_all(&mut dec, &full_frame[mid..]);
326        assert!(!stop2);
327        assert!(
328            second_events
329                .iter()
330                .any(|e| matches!(e, LlmStreamEvent::TextDelta { .. })),
331            "TextDelta should be emitted once the newline arrives"
332        );
333    }
334}