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::future::Future;
29use std::pin::Pin;
30use std::sync::Arc;
31use std::task::{Context, Poll};
32
33use tokio_stream::Stream;
34
35use crate::api::event::{BaseEvent, MarkEvent};
36use crate::api::llm::LlmHandle;
37use crate::api::llm::emit_reserved_optimization_marks;
38use crate::api::optimization::finalize_optimization_summary;
39use crate::api::registry::Guardrail;
40use crate::api::runtime::NemoRelayContextState;
41use crate::api::runtime::global_context;
42use crate::api::runtime::subscriber_dispatcher;
43use crate::api::runtime::{
44    EventSubscriberFn, LlmJsonStream, LlmStreamInner, ScopeStackHandle, TASK_SCOPE_STACK,
45    current_scope_stack,
46};
47use crate::api::runtime::{LlmSanitizeResponseContext, LlmSanitizeResponseFn};
48use crate::api::shared::{
49    metadata_with_otel_error, metadata_with_otel_status, snapshot_event_sanitizers,
50};
51use crate::codec::response::{
52    AnnotatedLlmResponse, FinishReason, attach_estimated_cost_for_provider,
53};
54use crate::codec::traits::LlmResponseCodec;
55use crate::error::{FlowError, Result};
56use crate::json::Json;
57use serde_json::Map;
58
59/// Wraps an inner `Stream<Item = Result<Json>>` of raw chunks and:
60///
61/// 1. Passes each chunk to the user-supplied **collector** closure.
62///    If the collector returns `Err`, the stream terminates with that error.
63/// 2. On stream exhaustion or explicit close, calls the **finalizer** to
64///    produce an aggregated [`Json`] response, runs sanitize response
65///    guardrails on it, then emits the LLM END event. Explicit close marks the
66///    end event as interrupted and waits for producer cleanup.
67///
68/// This type is returned by [`crate::api::llm::llm_stream_call_execute`] and
69/// is usually consumed as an ordinary async stream. Consumers that stop early
70/// should call [`LlmJsonStream::close`] to perform deterministic cleanup. The
71/// wrapper preserves the originating scope stack so end-of-stream bookkeeping
72/// still uses the correct scope-local middleware and subscribers even when
73/// polling happens elsewhere.
74pub struct LlmStreamWrapper {
75    inner: LlmJsonStream,
76    handle: LlmHandle,
77    scope_stack: ScopeStackHandle,
78    collector: Box<dyn FnMut(Json) -> Result<()> + Send>,
79    finalizer: Option<Box<dyn FnOnce() -> Json + Send>>,
80    response_codec: Option<Arc<dyn LlmResponseCodec>>,
81    sanitize_context: LlmSanitizeResponseContext,
82    metadata: Option<Json>,
83    subscribers: Vec<EventSubscriberFn>,
84    chunk_index: u64,
85    ended: bool,
86    close_result: Option<Result<()>>,
87    finalization: Option<tokio::task::JoinHandle<()>>,
88    terminal_result: Option<Result<Json>>,
89}
90
91#[derive(Clone, Copy, PartialEq, Eq)]
92enum StreamTermination {
93    Complete,
94    Failed,
95    Dropped,
96}
97
98impl StreamTermination {
99    const fn is_interrupted(self) -> bool {
100        !matches!(self, Self::Complete)
101    }
102}
103
104impl LlmStreamWrapper {
105    /// Create a new `LlmStreamWrapper` around the given raw stream.
106    ///
107    /// Captures the current [`ScopeStackHandle`] at creation time so the
108    /// correct scope stack is used when the stream is later polled, even if
109    /// polling happens on a different task or thread.
110    ///
111    /// # Parameters
112    /// - `inner`: Raw stream of JSON chunks from the provider callback.
113    /// - `handle`: [`LlmHandle`] identifying the managed LLM span.
114    /// - `collector`: Per-chunk callback used to accumulate stream state or
115    ///   forward chunks elsewhere. Returning `Err` terminates the stream.
116    /// - `finalizer`: One-shot callback invoked when the stream finishes to
117    ///   synthesize the aggregated response payload.
118    /// - `data`: Retained compatibility payload; Agent Trajectory
119    ///   Observability Format (ATOF) end data is the finalized response.
120    /// - `metadata`: Optional event metadata merged into the emitted LLM-end event.
121    /// - `response_codec`: Optional codec used to derive annotated response
122    ///   metadata from the aggregated final payload.
123    ///
124    /// # Returns
125    /// A new [`LlmStreamWrapper`] ready to be polled.
126    pub fn new(
127        inner: LlmJsonStream,
128        handle: LlmHandle,
129        collector: Box<dyn FnMut(Json) -> Result<()> + Send>,
130        finalizer: Box<dyn FnOnce() -> Json + Send>,
131        _data: Option<Json>,
132        metadata: Option<Json>,
133        response_codec: Option<Arc<dyn LlmResponseCodec>>,
134    ) -> Self {
135        let subscribers = {
136            let scope_stack = current_scope_stack();
137            let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
138            let scope_subscribers = scope_guard.collect_scope_local_subscribers();
139            let context = global_context();
140            context
141                .read()
142                .map(|state| state.collect_event_subscribers(&scope_subscribers))
143                .unwrap_or_default()
144        };
145        Self::new_managed(
146            inner,
147            handle,
148            collector,
149            finalizer,
150            metadata,
151            response_codec,
152            subscribers,
153        )
154    }
155
156    pub(crate) fn new_managed(
157        inner: LlmJsonStream,
158        handle: LlmHandle,
159        collector: Box<dyn FnMut(Json) -> Result<()> + Send>,
160        finalizer: Box<dyn FnOnce() -> Json + Send>,
161        metadata: Option<Json>,
162        response_codec: Option<Arc<dyn LlmResponseCodec>>,
163        subscribers: Vec<EventSubscriberFn>,
164    ) -> Self {
165        let scope_stack = handle.captured_scope_stack().clone();
166        let sanitize_context =
167            LlmSanitizeResponseContext::for_response_codec(response_codec.clone());
168        Self {
169            inner,
170            handle,
171            scope_stack,
172            collector,
173            finalizer: Some(finalizer),
174            response_codec,
175            sanitize_context,
176            metadata,
177            subscribers,
178            chunk_index: 0,
179            ended: false,
180            close_result: None,
181            finalization: None,
182            terminal_result: None,
183        }
184    }
185
186    /// Return the captured scope stack handle for this stream.
187    ///
188    /// Callers can use this to bind the correct scope stack when spawning
189    /// the stream on a different task via `TASK_SCOPE_STACK.scope(...)`.
190    ///
191    /// # Returns
192    /// A shared reference to the [`ScopeStackHandle`] captured when the stream
193    /// wrapper was created.
194    pub fn scope_stack(&self) -> &ScopeStackHandle {
195        &self.scope_stack
196    }
197
198    fn finish(&mut self, background_thread: bool) {
199        if self.ended {
200            return;
201        }
202        self.ended = true;
203        let metadata = metadata_with_otel_status(
204            self.metadata.clone(),
205            "ERROR",
206            Some("stream dropped before clean completion".to_string()),
207        );
208        // Drop cannot await the async finalizer. Seal contribution acceptance
209        // immediately, but let the finalizer decide whether authoritative
210        // terminal usage means the stream should be marked interrupted.
211        self.handle
212            .optimization_recorder
213            .close_for_finalization(None);
214        self.finalization =
215            self.emit_end_event(metadata, StreamTermination::Dropped, background_thread);
216    }
217
218    fn finish_cleanly(&mut self) {
219        if self.ended {
220            return;
221        }
222        self.ended = true;
223        self.inner.terminalize();
224        let metadata = metadata_with_otel_status(self.metadata.clone(), "OK", None);
225        self.finalization = self.emit_end_event(metadata, StreamTermination::Complete, false);
226    }
227
228    fn finish_with_error(&mut self, error: &FlowError) {
229        if self.ended {
230            return;
231        }
232        self.ended = true;
233        self.inner.terminalize();
234        let metadata = metadata_with_otel_error(self.metadata.clone(), error);
235        self.finalization = self.emit_end_event(metadata, StreamTermination::Failed, false);
236    }
237
238    /// Emit the LLM END event with aggregated response data.
239    ///
240    /// Calls the finalizer to produce the aggregated response, runs sanitize
241    /// response guardrails, and emits the END event.
242    fn emit_end_event(
243        &mut self,
244        metadata: Option<Json>,
245        termination: StreamTermination,
246        background_thread: bool,
247    ) -> Option<tokio::task::JoinHandle<()>> {
248        // The finalizer below runs on the caller's Tokio runtime. Register a
249        // dispatcher barrier before spawning it so a synchronous subscriber
250        // flush after this stream is dropped cannot overtake the END event.
251        let publication_barrier = subscriber_dispatcher::register_async_publication();
252        let aggregated = match self.finalizer.take() {
253            Some(finalizer) => finalizer(),
254            None => Json::Null,
255        };
256        let response_was_null_without_fallback = aggregated.is_null() && self.handle.data.is_none();
257        let response = if aggregated.is_null() {
258            self.handle.data.clone().unwrap_or(aggregated)
259        } else {
260            aggregated
261        };
262
263        let (entries, sanitizer_snapshot_failed) =
264            snapshot_stream_end_sanitizers(&self.scope_stack);
265        let handle = self.handle.clone();
266        let scope_stack = self.scope_stack.clone();
267        let finalization_scope_stack = scope_stack.clone();
268        let subscribers = self.subscribers.clone();
269        let response_codec = self.response_codec.clone();
270        let sanitize_context = self.sanitize_context.clone();
271        let finalize = async move {
272            let sanitized = (!sanitizer_snapshot_failed).then(|| {
273                NemoRelayContextState::llm_sanitize_response_snapshot_chain(
274                    response,
275                    sanitize_context,
276                    &entries,
277                )
278            });
279            let sanitized = match sanitized {
280                Some(sanitized) => sanitized.await,
281                None => None,
282            };
283            let data = match sanitized {
284                Some(response) if response_was_null_without_fallback && response.is_null() => None,
285                response => response,
286            };
287            let annotation_omitted = data.as_ref().is_none_or(Json::is_null);
288            let mut annotated_response: Option<AnnotatedLlmResponse> = (!annotation_omitted)
289                .then(|| {
290                    data.as_ref().and_then(|response| {
291                        response_codec.as_ref().and_then(|codec| {
292                            let mut decoded = codec.decode_response(response).ok()?;
293                            attach_estimated_cost_for_provider(&mut decoded, Some(&handle.name));
294                            Some(decoded)
295                        })
296                    })
297                })
298                .flatten();
299            let metadata = if termination == StreamTermination::Dropped
300                && has_authoritative_terminal_outcome(annotated_response.as_ref())
301            {
302                metadata_with_otel_status(metadata, "OK", None)
303            } else {
304                metadata
305            };
306            let interruption = (termination.is_interrupted()
307                && !has_authoritative_final_usage(annotated_response.as_ref()))
308            .then_some("stream_interrupted");
309            handle
310                .optimization_recorder
311                .close_for_finalization(interruption);
312            emit_reserved_optimization_marks(&handle, &subscribers).await;
313            let pricing = crate::codec::response::active_pricing_resolver();
314            let summary = finalize_optimization_summary(
315                &handle.optimization_recorder,
316                annotated_response.as_mut(),
317                handle.model_name.as_deref(),
318                &pricing,
319            );
320            if !annotation_omitted
321                && annotated_response.is_none()
322                && let Some(summary) = summary
323            {
324                annotated_response = Some(AnnotatedLlmResponse {
325                    optimization_summary: Some(summary),
326                    ..AnnotatedLlmResponse::default()
327                });
328            }
329            let annotated_response = annotated_response.map(Arc::new);
330            let event_snapshot = {
331                let ctx = global_context();
332                let state = ctx.read();
333                match state {
334                    Ok(state) => {
335                        Some(state.end_llm_handle(&handle, data, metadata, annotated_response))
336                    }
337                    Err(_) => None,
338                }
339            };
340            if let Some(event) = event_snapshot {
341                let sanitizers =
342                    snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
343                let _ = subscriber_dispatcher::dispatch_reserved_sanitized_event(
344                    event,
345                    sanitizers,
346                    &subscribers,
347                    scope_stack.clone(),
348                );
349            }
350        };
351        let finalize = TASK_SCOPE_STACK.scope(finalization_scope_stack, finalize);
352        let publication_context = subscriber_dispatcher::capture_publication_context();
353        let finalize = subscriber_dispatcher::with_task_publication_context(
354            publication_context,
355            subscriber_dispatcher::with_async_publication_context(publication_barrier, finalize),
356        );
357        if background_thread {
358            // `Drop` cannot await middleware and may run while the caller's
359            // executor is synchronously flushing subscribers. A process-local
360            // executor polls all detached finalizers on one shared OS thread.
361            // Pending middleware therefore does not create one thread per
362            // abandoned stream.
363            let _ = subscriber_dispatcher::spawn_background_publication(finalize);
364            return None;
365        }
366        match tokio::runtime::Handle::try_current() {
367            Ok(handle) => Some(handle.spawn(finalize)),
368            Err(_) => {
369                let _ = subscriber_dispatcher::spawn_background_publication(finalize);
370                None
371            }
372        }
373    }
374
375    /// Emit a compact per-chunk receipt mark before collector processing.
376    fn emit_chunk_mark(&self, chunk_index: u64, raw_chunk: &Json) {
377        let data = llm_chunk_mark_data(chunk_index, raw_chunk);
378        let event_snapshot = {
379            let ctx = global_context();
380            let state = ctx.read();
381            match state {
382                Ok(state) => {
383                    let event = state.create_event(MarkEvent::new(
384                        BaseEvent::builder()
385                            .name("llm.chunk")
386                            .parent_uuid(self.handle.uuid)
387                            .data(data)
388                            .build(),
389                        None,
390                        None,
391                    ));
392                    Some(event)
393                }
394                Err(_) => None,
395            }
396        };
397        if let Some(event) = event_snapshot {
398            let sanitizers =
399                snapshot_event_sanitizers(&event, &self.scope_stack).unwrap_or_default();
400            let _ = subscriber_dispatcher::dispatch_sanitized_event(
401                event,
402                sanitizers,
403                &self.subscribers,
404                self.scope_stack.clone(),
405            );
406        }
407    }
408}
409
410fn snapshot_stream_end_sanitizers(
411    scope_stack: &ScopeStackHandle,
412) -> (Vec<Guardrail<LlmSanitizeResponseFn>>, bool) {
413    let entries = scope_stack.read().ok().and_then(|scope_guard| {
414        let scope_locals = scope_guard
415            .collect_scope_local_registries(|registry| &registry.llm_sanitize_response_guardrails);
416        global_context()
417            .read()
418            .ok()
419            .map(|state| state.llm_sanitize_response_entries(&scope_locals))
420    });
421    match entries {
422        Some(entries) => (entries, false),
423        None => {
424            log::error!(
425                target: "nemo_relay.runtime",
426                event = "stream_end_sanitizer_snapshot_failed";
427                "LLM stream END sanitizer snapshot failed; omitting the observability payload"
428            );
429            (Vec::new(), true)
430        }
431    }
432}
433
434impl Stream for LlmStreamWrapper {
435    type Item = Result<Json>;
436
437    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
438        let this = self.as_mut().get_mut();
439
440        // The END event runs async because response and event sanitizers may
441        // await. Do not expose stream termination until that work has queued
442        // the event: callers commonly flush subscribers immediately after
443        // exhausting a stream, and that flush must include its END event.
444        if let Some(finalization) = this.finalization.as_mut() {
445            return match Pin::new(finalization).poll(cx) {
446                Poll::Pending => Poll::Pending,
447                Poll::Ready(Ok(())) => {
448                    this.finalization = None;
449                    match this.terminal_result.take() {
450                        Some(result) => Poll::Ready(Some(result)),
451                        None => Poll::Ready(None),
452                    }
453                }
454                Poll::Ready(Err(error)) => {
455                    this.finalization = None;
456                    Poll::Ready(Some(Err(FlowError::Internal(format!(
457                        "stream finalization task failed: {error}"
458                    )))))
459                }
460            };
461        }
462
463        if this.ended {
464            return match this.terminal_result.take() {
465                Some(result) => Poll::Ready(Some(result)),
466                None => Poll::Ready(None),
467            };
468        }
469
470        // Poll the inner stream
471        match Pin::new(&mut this.inner).poll_next(cx) {
472            Poll::Ready(Some(Ok(raw_chunk))) => {
473                let chunk_index = this.chunk_index;
474                this.chunk_index += 1;
475                this.emit_chunk_mark(chunk_index, &raw_chunk);
476                // Feed chunk to the collector; if it returns Err, terminate the stream
477                match (this.collector)(raw_chunk.clone()) {
478                    Ok(()) => Poll::Ready(Some(Ok(raw_chunk))),
479                    Err(e) => {
480                        this.finish_with_error(&e);
481                        this.terminal_result = Some(Err(e));
482                        self.poll_next(cx)
483                    }
484                }
485            }
486            Poll::Ready(Some(Err(e))) => {
487                this.finish_with_error(&e);
488                this.terminal_result = Some(Err(e));
489                self.poll_next(cx)
490            }
491            Poll::Ready(None) => {
492                this.finish_cleanly();
493                self.poll_next(cx)
494            }
495            Poll::Pending => Poll::Pending,
496        }
497    }
498}
499
500impl LlmStreamInner for LlmStreamWrapper {
501    fn close(self: Pin<&mut Self>) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
502        let this = self.get_mut();
503        Box::pin(async move {
504            if let Some(result) = &this.close_result {
505                return result.clone();
506            }
507            let result = this.inner.close().await;
508            this.finish(false);
509            if let Some(finalization) = this.finalization.take() {
510                finalization.await.map_err(|error| {
511                    FlowError::Internal(format!("stream finalization task failed: {error}"))
512                })?;
513            }
514            this.close_result = Some(result.clone());
515            this.close_result
516                .as_ref()
517                .expect("close result was just stored")
518                .clone()
519        })
520    }
521}
522
523fn has_authoritative_final_usage(response: Option<&AnnotatedLlmResponse>) -> bool {
524    response.is_some_and(|response| {
525        response.finish_reason.is_some()
526            && response.usage.as_ref().is_some_and(|usage| {
527                usage.total_tokens.is_some()
528                    || (usage.prompt_tokens.is_some() && usage.completion_tokens.is_some())
529            })
530    })
531}
532
533fn has_authoritative_terminal_outcome(response: Option<&AnnotatedLlmResponse>) -> bool {
534    has_authoritative_final_usage(response)
535        && response.is_some_and(|response| {
536            response
537                .finish_reason
538                .as_ref()
539                .is_some_and(|reason| !matches!(reason, FinishReason::Unknown(_)))
540        })
541}
542
543fn llm_chunk_mark_data(chunk_index: u64, raw_chunk: &Json) -> Json {
544    if let Some(data) = summarize_openai_chat_chunk(chunk_index, raw_chunk) {
545        return data;
546    }
547    if let Some(data) = summarize_openai_responses_chunk(chunk_index, raw_chunk) {
548        return data;
549    }
550    if let Some(data) = summarize_anthropic_messages_chunk(chunk_index, raw_chunk) {
551        return data;
552    }
553    Json::Object(base_chunk_mark_data(chunk_index, "unknown"))
554}
555
556fn base_chunk_mark_data(chunk_index: u64, provider: &str) -> Map<String, Json> {
557    let mut data = Map::new();
558    data.insert("chunk_index".into(), Json::from(chunk_index));
559    data.insert("provider".into(), Json::String(provider.to_string()));
560    data
561}
562
563fn summarize_openai_chat_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
564    let object = raw_chunk.get("object").and_then(Json::as_str);
565    let choices = raw_chunk.get("choices").and_then(Json::as_array);
566    if object != Some("chat.completion.chunk") {
567        return None;
568    }
569
570    let mut data = base_chunk_mark_data(chunk_index, "openai_chat_completions");
571    if let Some(object) = object {
572        data.insert("event_type".into(), Json::String(object.to_string()));
573    }
574    if let Some(choices) = choices {
575        let choice_indices: Vec<Json> = choices
576            .iter()
577            .filter_map(|choice| choice.get("index").and_then(Json::as_u64).map(Json::from))
578            .collect();
579        if !choice_indices.is_empty() {
580            data.insert("choice_indices".into(), Json::Array(choice_indices));
581        }
582
583        let finish_reasons: Vec<Json> = choices
584            .iter()
585            .filter_map(|choice| {
586                let reason = choice.get("finish_reason").and_then(Json::as_str)?;
587                let mut item = Map::new();
588                if let Some(index) = choice.get("index").and_then(Json::as_u64) {
589                    item.insert("choice_index".into(), Json::from(index));
590                }
591                item.insert("finish_reason".into(), Json::String(reason.to_string()));
592                Some(Json::Object(item))
593            })
594            .collect();
595        if !finish_reasons.is_empty() {
596            data.insert("finish_reasons".into(), Json::Array(finish_reasons));
597        }
598    }
599    if let Some(usage) = raw_chunk.get("usage").and_then(normalize_openai_chat_usage) {
600        data.insert("usage".into(), usage);
601    }
602
603    Some(Json::Object(data))
604}
605
606fn summarize_openai_responses_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
607    let event_type = raw_chunk.get("type").and_then(Json::as_str)?;
608    if !event_type.starts_with("response.") {
609        return None;
610    }
611
612    let mut data = base_chunk_mark_data(chunk_index, "openai_responses");
613    data.insert("event_type".into(), Json::String(event_type.to_string()));
614    insert_index_fields(&mut data, raw_chunk, &["output_index", "content_index"]);
615
616    if let Some(status) = raw_chunk
617        .get("response")
618        .and_then(|response| response.get("status"))
619        .or_else(|| raw_chunk.get("status"))
620        .and_then(Json::as_str)
621    {
622        data.insert("status".into(), Json::String(status.to_string()));
623    }
624    if let Some(reason) = raw_chunk
625        .get("response")
626        .and_then(|response| response.get("incomplete_details"))
627        .and_then(|details| details.get("reason"))
628        .and_then(Json::as_str)
629    {
630        data.insert("finish_reason".into(), Json::String(reason.to_string()));
631    }
632    if let Some(usage) = raw_chunk
633        .get("usage")
634        .or_else(|| {
635            raw_chunk
636                .get("response")
637                .and_then(|response| response.get("usage"))
638        })
639        .and_then(normalize_openai_responses_usage)
640    {
641        data.insert("usage".into(), usage);
642    }
643
644    Some(Json::Object(data))
645}
646
647fn summarize_anthropic_messages_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
648    let event_type = raw_chunk.get("type").and_then(Json::as_str)?;
649    if !matches!(
650        event_type,
651        "message_start"
652            | "content_block_start"
653            | "content_block_delta"
654            | "content_block_stop"
655            | "message_delta"
656            | "message_stop"
657            | "ping"
658    ) {
659        return None;
660    }
661
662    let mut data = base_chunk_mark_data(chunk_index, "anthropic_messages");
663    data.insert("event_type".into(), Json::String(event_type.to_string()));
664    insert_index_fields(&mut data, raw_chunk, &["index"]);
665
666    if let Some(stop_reason) = raw_chunk
667        .get("delta")
668        .and_then(|delta| delta.get("stop_reason"))
669        .or_else(|| {
670            raw_chunk
671                .get("message")
672                .and_then(|message| message.get("stop_reason"))
673        })
674        .and_then(Json::as_str)
675    {
676        data.insert("stop_reason".into(), Json::String(stop_reason.to_string()));
677    }
678    if let Some(usage) = raw_chunk
679        .get("usage")
680        .or_else(|| {
681            raw_chunk
682                .get("message")
683                .and_then(|message| message.get("usage"))
684        })
685        .and_then(normalize_anthropic_usage)
686    {
687        data.insert("usage".into(), usage);
688    }
689
690    Some(Json::Object(data))
691}
692
693fn insert_index_fields(data: &mut Map<String, Json>, raw_chunk: &Json, field_names: &[&str]) {
694    let mut indices = Map::new();
695    for field_name in field_names {
696        if let Some(index) = raw_chunk.get(*field_name).and_then(Json::as_u64) {
697            indices.insert((*field_name).to_string(), Json::from(index));
698        }
699    }
700    if !indices.is_empty() {
701        data.insert("indices".into(), Json::Object(indices));
702    }
703}
704
705fn normalize_openai_chat_usage(usage: &Json) -> Option<Json> {
706    let mut normalized = Map::new();
707    insert_u64_field(&mut normalized, usage, "prompt_tokens", "prompt_tokens");
708    insert_u64_field(
709        &mut normalized,
710        usage,
711        "completion_tokens",
712        "completion_tokens",
713    );
714    insert_u64_field(&mut normalized, usage, "total_tokens", "total_tokens");
715    if let Some(cached_tokens) = usage
716        .get("prompt_tokens_details")
717        .and_then(|details| details.get("cached_tokens"))
718        .and_then(Json::as_u64)
719    {
720        normalized.insert("cache_read_tokens".into(), Json::from(cached_tokens));
721    }
722    non_empty_object(normalized)
723}
724
725fn normalize_openai_responses_usage(usage: &Json) -> Option<Json> {
726    let mut normalized = Map::new();
727    insert_u64_field(&mut normalized, usage, "input_tokens", "prompt_tokens");
728    insert_u64_field(&mut normalized, usage, "output_tokens", "completion_tokens");
729    insert_u64_field(&mut normalized, usage, "total_tokens", "total_tokens");
730    if let Some(cached_tokens) = usage
731        .get("input_tokens_details")
732        .and_then(|details| details.get("cached_tokens"))
733        .and_then(Json::as_u64)
734    {
735        normalized.insert("cache_read_tokens".into(), Json::from(cached_tokens));
736    }
737    non_empty_object(normalized)
738}
739
740fn normalize_anthropic_usage(usage: &Json) -> Option<Json> {
741    let mut normalized = Map::new();
742    let prompt_tokens = usage.get("input_tokens").and_then(Json::as_u64);
743    let completion_tokens = usage.get("output_tokens").and_then(Json::as_u64);
744    if let Some(prompt_tokens) = prompt_tokens {
745        normalized.insert("prompt_tokens".into(), Json::from(prompt_tokens));
746    }
747    if let Some(completion_tokens) = completion_tokens {
748        normalized.insert("completion_tokens".into(), Json::from(completion_tokens));
749    }
750    if let Some(total_tokens) = prompt_tokens
751        .and_then(|prompt| completion_tokens.and_then(|completion| prompt.checked_add(completion)))
752    {
753        normalized.insert("total_tokens".into(), Json::from(total_tokens));
754    }
755    insert_u64_field(
756        &mut normalized,
757        usage,
758        "cache_read_input_tokens",
759        "cache_read_tokens",
760    );
761    insert_u64_field(
762        &mut normalized,
763        usage,
764        "cache_creation_input_tokens",
765        "cache_write_tokens",
766    );
767    non_empty_object(normalized)
768}
769
770fn insert_u64_field(
771    output: &mut Map<String, Json>,
772    input: &Json,
773    input_field: &str,
774    output_field: &str,
775) {
776    if let Some(value) = input.get(input_field).and_then(Json::as_u64) {
777        output.insert(output_field.to_string(), Json::from(value));
778    }
779}
780
781fn non_empty_object(object: Map<String, Json>) -> Option<Json> {
782    if object.is_empty() {
783        None
784    } else {
785        Some(Json::Object(object))
786    }
787}
788
789impl Drop for LlmStreamWrapper {
790    fn drop(&mut self) {
791        self.finish(true);
792    }
793}
794
795#[cfg(test)]
796#[path = "../tests/unit/stream_tests.rs"]
797mod tests;