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;
28use serde::{Deserialize, Serialize};
29
30/// Provider-neutral incremental stream item used by cross-protocol transcoders.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum NormalizedStreamEvent {
34 /// Assistant text delta.
35 TextDelta {
36 /// Newly generated text.
37 text: String,
38 },
39 /// Start of a function tool call.
40 ToolCallStart {
41 /// Stable tool-call identifier.
42 id: String,
43 /// Function name.
44 name: String,
45 },
46 /// Incremental JSON arguments for a function tool call.
47 ToolCallArgumentsDelta {
48 /// Stable tool-call identifier.
49 id: String,
50 /// Newly generated argument bytes.
51 delta: String,
52 },
53 /// Provider-reported terminal reason.
54 Finish {
55 /// Normalized or provider-native finish label.
56 reason: Option<String>,
57 },
58 /// Incremental or terminal usage object.
59 Usage {
60 /// Provider-neutral usage JSON.
61 usage: Json,
62 },
63 /// Provider stream error payload.
64 Error {
65 /// Structured provider error.
66 error: Json,
67 },
68}
69
70/// Per-provider streaming codec used with [`crate::api::llm::llm_stream_call_execute`].
71///
72/// `collector()` and `finalizer()` produce owned closures that share the codec's internal
73/// accumulation state. Implementations typically wrap that state in `Arc<Mutex<...>>` so each
74/// `&self`-produced closure captures a clone of the handle.
75///
76/// [`LlmFinalizerFn`] is `FnOnce`, so a [`StreamingCodec`] instance is single-use: callers
77/// construct a fresh instance per managed-lifecycle call and discard it after the stream
78/// completes.
79pub trait StreamingCodec: Send + Sync {
80 /// Returns a closure that consumes one decoded provider event per call.
81 fn collector(&self) -> LlmCollectorFn;
82
83 /// Returns a closure that, when called once at end of stream, produces the assembled response
84 /// payload in the shape the matching [`crate::codec::traits::LlmResponseCodec`] can decode.
85 fn finalizer(&self) -> LlmFinalizerFn;
86}
87
88/// Incremental decoder for `text/event-stream` byte streams that yields one JSON object per
89/// complete `data:` payload.
90///
91/// SSE frames are separated by blank lines (`\n\n`); each frame may contain `event:` and `data:`
92/// lines. Anthropic Messages, OpenAI Responses, and OpenAI Chat Completions all emit one JSON
93/// object per `data:` line, so the decoder buffers received bytes, splits on frame boundaries,
94/// parses the JSON payload, and tags it with the frame's event name when present.
95///
96/// The decoder is byte-stream-friendly: it accumulates partial frames across chunks and emits
97/// completed frames only when their terminating blank line arrives. Bytes after the last
98/// terminator are retained for the next call.
99#[derive(Default)]
100pub struct SseEventDecoder {
101 buffer: String,
102}
103
104/// One decoded SSE frame, paired with the parsed `data:` payload.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct SseEvent {
107 /// Value of the `event:` line if present.
108 pub event: Option<String>,
109 /// Parsed JSON payload from the `data:` line(s).
110 pub data: Json,
111}
112
113impl SseEventDecoder {
114 /// Creates a new decoder with an empty buffer.
115 pub fn new() -> Self {
116 Self::default()
117 }
118
119 /// Appends `bytes` to the internal buffer and returns every now-complete SSE event.
120 ///
121 /// Bytes are interpreted as UTF-8 with replacement characters for invalid sequences; provider
122 /// SSE streams are well-formed UTF-8 in practice, but lossy decoding keeps the decoder honest
123 /// rather than failing on a single corrupt chunk.
124 ///
125 /// Returns `Ok(events)` containing zero or more events whose `data:` payloads parsed
126 /// successfully. Frames whose `data:` line is non-empty but does not parse as JSON are
127 /// surfaced as [`FlowError::Internal`] so the caller can decide whether to abort the stream
128 /// or skip the frame; frames with no `data:` line at all (e.g. SSE heartbeats) are silently
129 /// dropped.
130 pub fn push_bytes(&mut self, bytes: &[u8]) -> Result<Vec<SseEvent>> {
131 self.push_bytes_results(bytes).into_iter().collect()
132 }
133
134 /// Appends `bytes` and returns each completed frame's result in wire order.
135 ///
136 /// Unlike [`Self::push_bytes`], this preserves successful events that precede a malformed
137 /// frame in the same byte batch. Decoding stops after the first error so callers can emit the
138 /// preceding events, surface the error, and terminate the stream.
139 pub fn push_bytes_results(&mut self, bytes: &[u8]) -> Vec<Result<SseEvent>> {
140 // Normalize CRLF to LF on append so the framing search only needs to find `\n\n`. Some
141 // providers emit mixed line endings on the wire; normalizing once here keeps the inner
142 // loop cheap. If CRLF is split across chunks, retain the trailing CR until the next append
143 // and remove it only when the next byte completes the sequence.
144 if self.buffer.ends_with('\r') && bytes.first() == Some(&b'\n') {
145 self.buffer.pop();
146 }
147 let chunk = String::from_utf8_lossy(bytes).replace("\r\n", "\n");
148 self.buffer.push_str(&chunk);
149 let mut results = Vec::new();
150 while let Some(cut) = self.buffer.find("\n\n") {
151 let frame: String = self.buffer.drain(..cut).collect();
152 // Drop the `\n\n` terminator itself.
153 self.buffer.drain(..2);
154 match parse_sse_frame(&frame) {
155 Ok(Some(event)) => results.push(Ok(event)),
156 Ok(None) => {}
157 Err(error) => {
158 results.push(Err(error));
159 break;
160 }
161 }
162 }
163 results
164 }
165
166 /// Drains any remaining buffered frame at end of stream.
167 ///
168 /// Most well-formed SSE streams end with a terminating blank line, in which case this returns
169 /// `Ok(None)`. Stops with no terminator are surfaced as a final partial frame so observability
170 /// captures the last bytes the upstream sent before disconnect.
171 pub fn finish(mut self) -> Result<Option<SseEvent>> {
172 let trailing = std::mem::take(&mut self.buffer);
173 if trailing.trim().is_empty() {
174 Ok(None)
175 } else {
176 parse_sse_frame(&trailing)
177 }
178 }
179}
180
181// Parses a single SSE frame. Returns `None` for frames without a `data:` line, `Some(event)` for
182// frames whose `data:` JSON parsed successfully.
183fn parse_sse_frame(frame: &str) -> Result<Option<SseEvent>> {
184 let mut event_name: Option<String> = None;
185 let mut data_parts: Vec<&str> = Vec::new();
186 for line in frame.split('\n') {
187 if let Some(rest) = line.strip_prefix("event:") {
188 event_name = Some(rest.trim().to_string());
189 } else if let Some(rest) = line.strip_prefix("data:") {
190 // SSE allows a single space after the colon by convention; strip it lazily.
191 data_parts.push(rest.strip_prefix(' ').unwrap_or(rest));
192 }
193 // Other lines (`id:`, `retry:`, comments starting with `:`) are ignored.
194 }
195 if data_parts.is_empty() {
196 return Ok(None);
197 }
198 let payload = data_parts.join("\n");
199 let trimmed = payload.trim();
200 // OpenAI Chat Completions emits a `data: [DONE]` terminator as a wire-level end-of-stream
201 // sentinel. It's not a JSON payload — drop it like a heartbeat. Other providers (Anthropic,
202 // OpenAI Responses) have proper terminal events instead, so this only fires for OpenAI Chat.
203 if trimmed == "[DONE]" {
204 return Ok(None);
205 }
206 let data: Json = serde_json::from_str(trimmed).map_err(|error| {
207 FlowError::Internal(format!(
208 "streaming codec failed to parse SSE data payload: {error}: {payload}"
209 ))
210 })?;
211 Ok(Some(SseEvent {
212 event: event_name,
213 data,
214 }))
215}
216
217#[cfg(test)]
218#[path = "../../tests/unit/codec/streaming_tests.rs"]
219mod tests;