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