Skip to main content

nemo_relay/
stream.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Streaming LLM response wrapper.
5//!
6//! This module provides [`LlmStreamWrapper`], a [`Stream`] adapter
7//! that sits between the raw stream from an LLM API and the consumer. It
8//! feeds chunks to a user-supplied collector, and automatically emits
9//! lifecycle events when the stream ends.
10//!
11//! ## Pipeline
12//!
13//! ```text
14//! raw chunk (Json) -> collector(chunk) -> Ok(()) -> yield chunk
15//!                                      -> Err(e) -> terminate stream with error
16//! upstream error -> terminate stream with error -> finalizer() -> Json -> SanitizeResponseGuardrails -> END event
17//! stream ends -> finalizer() -> Json -> SanitizeResponseGuardrails -> END event
18//! ```
19//!
20//! The **collector** receives each chunk (Json) and can accumulate state
21//! (e.g., concatenating tokens). If the collector returns `Err`, the stream
22//! terminates immediately with that error. Upstream stream errors also
23//! terminate the stream immediately. The **finalizer** is called once when the
24//! stream terminates and returns the aggregated response as [`Json`]. That
25//! aggregated response then flows through sanitize response guardrails before
26//! being included in the END event.
27
28use std::pin::Pin;
29use std::sync::Arc;
30use std::task::{Context, Poll};
31
32use tokio_stream::Stream;
33
34use crate::api::event::{BaseEvent, MarkEvent};
35use crate::api::llm::LlmHandle;
36use crate::api::runtime::NemoRelayContextState;
37use crate::api::runtime::global_context;
38use crate::api::runtime::{ScopeStackHandle, current_scope_stack};
39use crate::api::shared::metadata_with_otel_status;
40use crate::codec::response::{AnnotatedLlmResponse, attach_estimated_cost_for_provider};
41use crate::codec::traits::LlmResponseCodec;
42use crate::error::Result;
43use crate::json::Json;
44use serde_json::Map;
45
46/// Wraps an inner `Stream<Item = Result<Json>>` of raw chunks and:
47///
48/// 1. Passes each chunk to the user-supplied **collector** closure.
49///    If the collector returns `Err`, the stream terminates with that error.
50/// 2. On stream exhaustion, calls the **finalizer** to produce an aggregated
51///    [`Json`] response, runs sanitize response guardrails on it, then emits
52///    the LLM END event.
53///
54/// This type is returned by [`crate::api::llm::llm_stream_call_execute`] and
55/// is usually consumed as an ordinary async stream. The wrapper preserves the
56/// originating scope stack so end-of-stream bookkeeping still uses the correct
57/// scope-local middleware and subscribers even when polling happens elsewhere.
58pub struct LlmStreamWrapper {
59    inner: Pin<Box<dyn Stream<Item = Result<Json>> + Send>>,
60    handle: LlmHandle,
61    scope_stack: ScopeStackHandle,
62    collector: Box<dyn FnMut(Json) -> Result<()> + Send>,
63    finalizer: Option<Box<dyn FnOnce() -> Json + Send>>,
64    response_codec: Option<Arc<dyn LlmResponseCodec>>,
65    metadata: Option<Json>,
66    chunk_index: u64,
67    ended: bool,
68}
69
70impl LlmStreamWrapper {
71    /// Create a new `LlmStreamWrapper` around the given raw stream.
72    ///
73    /// Captures the current [`ScopeStackHandle`] at creation time so the
74    /// correct scope stack is used when the stream is later polled, even if
75    /// polling happens on a different task or thread.
76    ///
77    /// # Parameters
78    /// - `inner`: Raw stream of JSON chunks from the provider callback.
79    /// - `handle`: [`LlmHandle`] identifying the managed LLM span.
80    /// - `collector`: Per-chunk callback used to accumulate stream state or
81    ///   forward chunks elsewhere. Returning `Err` terminates the stream.
82    /// - `finalizer`: One-shot callback invoked when the stream finishes to
83    ///   synthesize the aggregated response payload.
84    /// - `data`: Retained compatibility payload; Agent Trajectory
85    ///   Observability Format (ATOF) end data is the finalized response.
86    /// - `metadata`: Optional event metadata merged into the emitted LLM-end event.
87    /// - `response_codec`: Optional codec used to derive annotated response
88    ///   metadata from the aggregated final payload.
89    ///
90    /// # Returns
91    /// A new [`LlmStreamWrapper`] ready to be polled.
92    pub fn new(
93        inner: Pin<Box<dyn Stream<Item = Result<Json>> + Send>>,
94        handle: LlmHandle,
95        collector: Box<dyn FnMut(Json) -> Result<()> + Send>,
96        finalizer: Box<dyn FnOnce() -> Json + Send>,
97        _data: Option<Json>,
98        metadata: Option<Json>,
99        response_codec: Option<Arc<dyn LlmResponseCodec>>,
100    ) -> Self {
101        Self {
102            inner,
103            handle,
104            scope_stack: current_scope_stack(),
105            collector,
106            finalizer: Some(finalizer),
107            response_codec,
108            metadata,
109            chunk_index: 0,
110            ended: false,
111        }
112    }
113
114    /// Return the captured scope stack handle for this stream.
115    ///
116    /// Callers can use this to bind the correct scope stack when spawning
117    /// the stream on a different task via `TASK_SCOPE_STACK.scope(...)`.
118    ///
119    /// # Returns
120    /// A shared reference to the [`ScopeStackHandle`] captured when the stream
121    /// wrapper was created.
122    pub fn scope_stack(&self) -> &ScopeStackHandle {
123        &self.scope_stack
124    }
125
126    fn finish(&mut self) {
127        if self.ended {
128            return;
129        }
130        self.ended = true;
131        self.emit_end_event(self.metadata.clone());
132    }
133
134    fn finish_with_status(&mut self, status_code: &'static str, status_message: Option<String>) {
135        if self.ended {
136            return;
137        }
138        self.ended = true;
139        let metadata =
140            metadata_with_otel_status(self.metadata.clone(), status_code, status_message);
141        self.emit_end_event(metadata);
142    }
143
144    /// Emit the LLM END event with aggregated response data.
145    ///
146    /// Calls the finalizer to produce the aggregated response, runs sanitize
147    /// response guardrails, and emits the END event.
148    fn emit_end_event(&mut self, metadata: Option<Json>) {
149        let aggregated = match self.finalizer.take() {
150            Some(finalizer) => finalizer(),
151            None => Json::Null,
152        };
153
154        let event_snapshot = {
155            let ss_guard = self.scope_stack.read().expect("scope stack lock poisoned");
156            let sl =
157                ss_guard.collect_scope_local_registries(|r| &r.llm_sanitize_response_guardrails);
158            let sl_subs = ss_guard.collect_scope_local_subscribers();
159            let ctx = global_context();
160            let state = ctx.read();
161            match state {
162                Ok(state) => {
163                    let subscribers = state.collect_event_subscribers(&sl_subs);
164                    let sanitized = state.llm_sanitize_response_chain(aggregated, &sl);
165                    let data = if sanitized.is_null() {
166                        self.handle.data.clone()
167                    } else {
168                        Some(sanitized)
169                    };
170                    let annotated_response: Option<Arc<AnnotatedLlmResponse>> = self
171                        .response_codec
172                        .as_ref()
173                        .and_then(|codec| {
174                            let mut decoded = codec.decode_response(data.as_ref()?).ok()?;
175                            attach_estimated_cost_for_provider(
176                                &mut decoded,
177                                Some(&self.handle.name),
178                            );
179                            Some(decoded)
180                        })
181                        .map(Arc::new);
182                    let event =
183                        state.end_llm_handle(&self.handle, data, metadata, annotated_response);
184                    Some((event, subscribers))
185                }
186                Err(_) => None,
187            }
188        };
189        if let Some((event, subscribers)) = event_snapshot {
190            NemoRelayContextState::emit_event(&event, &subscribers);
191        }
192    }
193
194    /// Emit a compact per-chunk receipt mark before collector processing.
195    fn emit_chunk_mark(&self, chunk_index: u64, raw_chunk: &Json) {
196        let data = llm_chunk_mark_data(chunk_index, raw_chunk);
197        let event_snapshot = {
198            let Ok(ss_guard) = self.scope_stack.read() else {
199                return;
200            };
201            let sl_subs = ss_guard.collect_scope_local_subscribers();
202            let ctx = global_context();
203            let state = ctx.read();
204            match state {
205                Ok(state) => {
206                    let subscribers = state.collect_event_subscribers(&sl_subs);
207                    let event = state.create_event(MarkEvent::new(
208                        BaseEvent::builder()
209                            .name("llm.chunk")
210                            .parent_uuid(self.handle.uuid)
211                            .data(data)
212                            .build(),
213                        None,
214                        None,
215                    ));
216                    Some((event, subscribers))
217                }
218                Err(_) => None,
219            }
220        };
221        if let Some((event, subscribers)) = event_snapshot {
222            NemoRelayContextState::emit_event(&event, &subscribers);
223        }
224    }
225}
226
227impl Stream for LlmStreamWrapper {
228    type Item = Result<Json>;
229
230    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
231        let this = self.get_mut();
232
233        if this.ended {
234            return Poll::Ready(None);
235        }
236
237        // Poll the inner stream
238        match this.inner.as_mut().poll_next(cx) {
239            Poll::Ready(Some(Ok(raw_chunk))) => {
240                let chunk_index = this.chunk_index;
241                this.chunk_index += 1;
242                this.emit_chunk_mark(chunk_index, &raw_chunk);
243                // Feed chunk to the collector; if it returns Err, terminate the stream
244                match (this.collector)(raw_chunk.clone()) {
245                    Ok(()) => Poll::Ready(Some(Ok(raw_chunk))),
246                    Err(e) => {
247                        let message = e.to_string();
248                        this.finish_with_status("ERROR", Some(message));
249                        Poll::Ready(Some(Err(e)))
250                    }
251                }
252            }
253            Poll::Ready(Some(Err(e))) => {
254                let message = e.to_string();
255                this.finish_with_status("ERROR", Some(message));
256                Poll::Ready(Some(Err(e)))
257            }
258            Poll::Ready(None) => {
259                this.finish_with_status("OK", None);
260                Poll::Ready(None)
261            }
262            Poll::Pending => Poll::Pending,
263        }
264    }
265}
266
267fn llm_chunk_mark_data(chunk_index: u64, raw_chunk: &Json) -> Json {
268    if let Some(data) = summarize_openai_chat_chunk(chunk_index, raw_chunk) {
269        return data;
270    }
271    if let Some(data) = summarize_openai_responses_chunk(chunk_index, raw_chunk) {
272        return data;
273    }
274    if let Some(data) = summarize_anthropic_messages_chunk(chunk_index, raw_chunk) {
275        return data;
276    }
277    Json::Object(base_chunk_mark_data(chunk_index, "unknown"))
278}
279
280fn base_chunk_mark_data(chunk_index: u64, provider: &str) -> Map<String, Json> {
281    let mut data = Map::new();
282    data.insert("chunk_index".into(), Json::from(chunk_index));
283    data.insert("provider".into(), Json::String(provider.to_string()));
284    data
285}
286
287fn summarize_openai_chat_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
288    let object = raw_chunk.get("object").and_then(Json::as_str);
289    let choices = raw_chunk.get("choices").and_then(Json::as_array);
290    if object != Some("chat.completion.chunk") {
291        return None;
292    }
293
294    let mut data = base_chunk_mark_data(chunk_index, "openai_chat_completions");
295    if let Some(object) = object {
296        data.insert("event_type".into(), Json::String(object.to_string()));
297    }
298    if let Some(choices) = choices {
299        let choice_indices: Vec<Json> = choices
300            .iter()
301            .filter_map(|choice| choice.get("index").and_then(Json::as_u64).map(Json::from))
302            .collect();
303        if !choice_indices.is_empty() {
304            data.insert("choice_indices".into(), Json::Array(choice_indices));
305        }
306
307        let finish_reasons: Vec<Json> = choices
308            .iter()
309            .filter_map(|choice| {
310                let reason = choice.get("finish_reason").and_then(Json::as_str)?;
311                let mut item = Map::new();
312                if let Some(index) = choice.get("index").and_then(Json::as_u64) {
313                    item.insert("choice_index".into(), Json::from(index));
314                }
315                item.insert("finish_reason".into(), Json::String(reason.to_string()));
316                Some(Json::Object(item))
317            })
318            .collect();
319        if !finish_reasons.is_empty() {
320            data.insert("finish_reasons".into(), Json::Array(finish_reasons));
321        }
322    }
323    if let Some(usage) = raw_chunk.get("usage").and_then(normalize_openai_chat_usage) {
324        data.insert("usage".into(), usage);
325    }
326
327    Some(Json::Object(data))
328}
329
330fn summarize_openai_responses_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
331    let event_type = raw_chunk.get("type").and_then(Json::as_str)?;
332    if !event_type.starts_with("response.") {
333        return None;
334    }
335
336    let mut data = base_chunk_mark_data(chunk_index, "openai_responses");
337    data.insert("event_type".into(), Json::String(event_type.to_string()));
338    insert_index_fields(&mut data, raw_chunk, &["output_index", "content_index"]);
339
340    if let Some(status) = raw_chunk
341        .get("response")
342        .and_then(|response| response.get("status"))
343        .or_else(|| raw_chunk.get("status"))
344        .and_then(Json::as_str)
345    {
346        data.insert("status".into(), Json::String(status.to_string()));
347    }
348    if let Some(reason) = raw_chunk
349        .get("response")
350        .and_then(|response| response.get("incomplete_details"))
351        .and_then(|details| details.get("reason"))
352        .and_then(Json::as_str)
353    {
354        data.insert("finish_reason".into(), Json::String(reason.to_string()));
355    }
356    if let Some(usage) = raw_chunk
357        .get("usage")
358        .or_else(|| {
359            raw_chunk
360                .get("response")
361                .and_then(|response| response.get("usage"))
362        })
363        .and_then(normalize_openai_responses_usage)
364    {
365        data.insert("usage".into(), usage);
366    }
367
368    Some(Json::Object(data))
369}
370
371fn summarize_anthropic_messages_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
372    let event_type = raw_chunk.get("type").and_then(Json::as_str)?;
373    if !matches!(
374        event_type,
375        "message_start"
376            | "content_block_start"
377            | "content_block_delta"
378            | "content_block_stop"
379            | "message_delta"
380            | "message_stop"
381            | "ping"
382    ) {
383        return None;
384    }
385
386    let mut data = base_chunk_mark_data(chunk_index, "anthropic_messages");
387    data.insert("event_type".into(), Json::String(event_type.to_string()));
388    insert_index_fields(&mut data, raw_chunk, &["index"]);
389
390    if let Some(stop_reason) = raw_chunk
391        .get("delta")
392        .and_then(|delta| delta.get("stop_reason"))
393        .or_else(|| {
394            raw_chunk
395                .get("message")
396                .and_then(|message| message.get("stop_reason"))
397        })
398        .and_then(Json::as_str)
399    {
400        data.insert("stop_reason".into(), Json::String(stop_reason.to_string()));
401    }
402    if let Some(usage) = raw_chunk
403        .get("usage")
404        .or_else(|| {
405            raw_chunk
406                .get("message")
407                .and_then(|message| message.get("usage"))
408        })
409        .and_then(normalize_anthropic_usage)
410    {
411        data.insert("usage".into(), usage);
412    }
413
414    Some(Json::Object(data))
415}
416
417fn insert_index_fields(data: &mut Map<String, Json>, raw_chunk: &Json, field_names: &[&str]) {
418    let mut indices = Map::new();
419    for field_name in field_names {
420        if let Some(index) = raw_chunk.get(*field_name).and_then(Json::as_u64) {
421            indices.insert((*field_name).to_string(), Json::from(index));
422        }
423    }
424    if !indices.is_empty() {
425        data.insert("indices".into(), Json::Object(indices));
426    }
427}
428
429fn normalize_openai_chat_usage(usage: &Json) -> Option<Json> {
430    let mut normalized = Map::new();
431    insert_u64_field(&mut normalized, usage, "prompt_tokens", "prompt_tokens");
432    insert_u64_field(
433        &mut normalized,
434        usage,
435        "completion_tokens",
436        "completion_tokens",
437    );
438    insert_u64_field(&mut normalized, usage, "total_tokens", "total_tokens");
439    if let Some(cached_tokens) = usage
440        .get("prompt_tokens_details")
441        .and_then(|details| details.get("cached_tokens"))
442        .and_then(Json::as_u64)
443    {
444        normalized.insert("cache_read_tokens".into(), Json::from(cached_tokens));
445    }
446    non_empty_object(normalized)
447}
448
449fn normalize_openai_responses_usage(usage: &Json) -> Option<Json> {
450    let mut normalized = Map::new();
451    insert_u64_field(&mut normalized, usage, "input_tokens", "prompt_tokens");
452    insert_u64_field(&mut normalized, usage, "output_tokens", "completion_tokens");
453    insert_u64_field(&mut normalized, usage, "total_tokens", "total_tokens");
454    if let Some(cached_tokens) = usage
455        .get("input_tokens_details")
456        .and_then(|details| details.get("cached_tokens"))
457        .and_then(Json::as_u64)
458    {
459        normalized.insert("cache_read_tokens".into(), Json::from(cached_tokens));
460    }
461    non_empty_object(normalized)
462}
463
464fn normalize_anthropic_usage(usage: &Json) -> Option<Json> {
465    let mut normalized = Map::new();
466    let prompt_tokens = usage.get("input_tokens").and_then(Json::as_u64);
467    let completion_tokens = usage.get("output_tokens").and_then(Json::as_u64);
468    if let Some(prompt_tokens) = prompt_tokens {
469        normalized.insert("prompt_tokens".into(), Json::from(prompt_tokens));
470    }
471    if let Some(completion_tokens) = completion_tokens {
472        normalized.insert("completion_tokens".into(), Json::from(completion_tokens));
473    }
474    if let Some(total_tokens) = prompt_tokens
475        .and_then(|prompt| completion_tokens.and_then(|completion| prompt.checked_add(completion)))
476    {
477        normalized.insert("total_tokens".into(), Json::from(total_tokens));
478    }
479    insert_u64_field(
480        &mut normalized,
481        usage,
482        "cache_read_input_tokens",
483        "cache_read_tokens",
484    );
485    insert_u64_field(
486        &mut normalized,
487        usage,
488        "cache_creation_input_tokens",
489        "cache_write_tokens",
490    );
491    non_empty_object(normalized)
492}
493
494fn insert_u64_field(
495    output: &mut Map<String, Json>,
496    input: &Json,
497    input_field: &str,
498    output_field: &str,
499) {
500    if let Some(value) = input.get(input_field).and_then(Json::as_u64) {
501        output.insert(output_field.to_string(), Json::from(value));
502    }
503}
504
505fn non_empty_object(object: Map<String, Json>) -> Option<Json> {
506    if object.is_empty() {
507        None
508    } else {
509        Some(Json::Object(object))
510    }
511}
512
513impl Drop for LlmStreamWrapper {
514    fn drop(&mut self) {
515        self.finish();
516    }
517}
518
519#[cfg(test)]
520#[path = "../tests/unit/stream_tests.rs"]
521mod tests;