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;