Skip to main content

nemo_relay/codec/
streaming.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Streaming response codecs for the managed LLM execution pipeline.
5//!
6//! [`crate::codec::traits::LlmResponseCodec`] decodes a complete provider response into a
7//! normalized [`AnnotatedLlmResponse`]. For streaming providers, the analogous job is to:
8//!
9//! 1. consume per-chunk events as they arrive on a streaming HTTP response, and
10//! 2. assemble a single non-streaming-shape JSON payload at end of stream.
11//!
12//! Once assembled, the payload can be fed back through the matching
13//! [`crate::codec::traits::LlmResponseCodec`] to produce an [`AnnotatedLlmResponse`] — meaning
14//! streaming and non-streaming requests converge on the same observability output without
15//! per-route shape duplication.
16//!
17//! [`StreamingCodec`] is the trait that bundles the two functions
18//! ([`LlmCollectorFn`],
19//! [`LlmFinalizerFn`]) used by
20//! [`crate::api::llm::llm_stream_call_execute`]. Each provider supplies one impl whose internal
21//! state holds whatever incremental information is needed to materialize the final payload.
22//!
23//! [`AnnotatedLlmResponse`]: crate::codec::response::AnnotatedLlmResponse
24
25use crate::api::runtime::{LlmCollectorFn, LlmFinalizerFn};
26use crate::error::{FlowError, Result};
27use crate::json::Json;
28
29/// Per-provider streaming codec used with [`crate::api::llm::llm_stream_call_execute`].
30///
31/// `collector()` and `finalizer()` produce owned closures that share the codec's internal
32/// accumulation state. Implementations typically wrap that state in `Arc<Mutex<...>>` so each
33/// `&self`-produced closure captures a clone of the handle.
34///
35/// [`LlmFinalizerFn`] is `FnOnce`, so a [`StreamingCodec`] instance is single-use: callers
36/// construct a fresh instance per managed-lifecycle call and discard it after the stream
37/// completes.
38pub trait StreamingCodec: Send + Sync {
39    /// Returns a closure that consumes one decoded provider event per call.
40    fn collector(&self) -> LlmCollectorFn;
41
42    /// Returns a closure that, when called once at end of stream, produces the assembled response
43    /// payload in the shape the matching [`crate::codec::traits::LlmResponseCodec`] can decode.
44    fn finalizer(&self) -> LlmFinalizerFn;
45}
46
47/// Incremental decoder for `text/event-stream` byte streams that yields one JSON object per
48/// complete `data:` payload.
49///
50/// SSE frames are separated by blank lines (`\n\n`); each frame may contain `event:` and `data:`
51/// lines. Anthropic Messages, OpenAI Responses, and OpenAI Chat Completions all emit one JSON
52/// object per `data:` line, so the decoder buffers received bytes, splits on frame boundaries,
53/// parses the JSON payload, and tags it with the frame's event name when present.
54///
55/// The decoder is byte-stream-friendly: it accumulates partial frames across chunks and emits
56/// completed frames only when their terminating blank line arrives. Bytes after the last
57/// terminator are retained for the next call.
58#[derive(Default)]
59pub struct SseEventDecoder {
60    buffer: String,
61}
62
63/// One decoded SSE frame, paired with the parsed `data:` payload.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SseEvent {
66    /// Value of the `event:` line if present.
67    pub event: Option<String>,
68    /// Parsed JSON payload from the `data:` line(s).
69    pub data: Json,
70}
71
72impl SseEventDecoder {
73    /// Creates a new decoder with an empty buffer.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Appends `bytes` to the internal buffer and returns every now-complete SSE event.
79    ///
80    /// Bytes are interpreted as UTF-8 with replacement characters for invalid sequences; provider
81    /// SSE streams are well-formed UTF-8 in practice, but lossy decoding keeps the decoder honest
82    /// rather than failing on a single corrupt chunk.
83    ///
84    /// Returns `Ok(events)` containing zero or more events whose `data:` payloads parsed
85    /// successfully. Frames whose `data:` line is non-empty but does not parse as JSON are
86    /// surfaced as [`FlowError::Internal`] so the caller can decide whether to abort the stream
87    /// or skip the frame; frames with no `data:` line at all (e.g. SSE heartbeats) are silently
88    /// dropped.
89    pub fn push_bytes(&mut self, bytes: &[u8]) -> Result<Vec<SseEvent>> {
90        // Normalize CRLF to LF on append so the framing search only needs to find `\n\n`. Some
91        // providers emit mixed line endings on the wire; normalizing once here keeps the inner
92        // loop cheap.
93        let chunk = String::from_utf8_lossy(bytes).replace("\r\n", "\n");
94        self.buffer.push_str(&chunk);
95        let mut events = Vec::new();
96        while let Some(cut) = self.buffer.find("\n\n") {
97            let frame: String = self.buffer.drain(..cut).collect();
98            // Drop the `\n\n` terminator itself.
99            self.buffer.drain(..2);
100            if let Some(event) = parse_sse_frame(&frame)? {
101                events.push(event);
102            }
103        }
104        Ok(events)
105    }
106
107    /// Drains any remaining buffered frame at end of stream.
108    ///
109    /// Most well-formed SSE streams end with a terminating blank line, in which case this returns
110    /// `Ok(None)`. Stops with no terminator are surfaced as a final partial frame so observability
111    /// captures the last bytes the upstream sent before disconnect.
112    pub fn finish(mut self) -> Result<Option<SseEvent>> {
113        let trailing = std::mem::take(&mut self.buffer);
114        if trailing.trim().is_empty() {
115            Ok(None)
116        } else {
117            parse_sse_frame(&trailing)
118        }
119    }
120}
121
122// Parses a single SSE frame. Returns `None` for frames without a `data:` line, `Some(event)` for
123// frames whose `data:` JSON parsed successfully.
124fn parse_sse_frame(frame: &str) -> Result<Option<SseEvent>> {
125    let mut event_name: Option<String> = None;
126    let mut data_parts: Vec<&str> = Vec::new();
127    for line in frame.split('\n') {
128        if let Some(rest) = line.strip_prefix("event:") {
129            event_name = Some(rest.trim().to_string());
130        } else if let Some(rest) = line.strip_prefix("data:") {
131            // SSE allows a single space after the colon by convention; strip it lazily.
132            data_parts.push(rest.strip_prefix(' ').unwrap_or(rest));
133        }
134        // Other lines (`id:`, `retry:`, comments starting with `:`) are ignored.
135    }
136    if data_parts.is_empty() {
137        return Ok(None);
138    }
139    let payload = data_parts.join("\n");
140    let trimmed = payload.trim();
141    // OpenAI Chat Completions emits a `data: [DONE]` terminator as a wire-level end-of-stream
142    // sentinel. It's not a JSON payload — drop it like a heartbeat. Other providers (Anthropic,
143    // OpenAI Responses) have proper terminal events instead, so this only fires for OpenAI Chat.
144    if trimmed == "[DONE]" {
145        return Ok(None);
146    }
147    let data: Json = serde_json::from_str(trimmed).map_err(|error| {
148        FlowError::Internal(format!(
149            "streaming codec failed to parse SSE data payload: {error}: {payload}"
150        ))
151    })?;
152    Ok(Some(SseEvent {
153        event: event_name,
154        data,
155    }))
156}
157
158#[cfg(test)]
159#[path = "../../tests/unit/codec/streaming_tests.rs"]
160mod tests;