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