1use 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
59pub 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 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 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 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 fn emit_end_event(
243 &mut self,
244 metadata: Option<Json>,
245 termination: StreamTermination,
246 background_thread: bool,
247 ) -> Option<tokio::task::JoinHandle<()>> {
248 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 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 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| ®istry.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 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 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 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;