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::{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
57pub 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 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 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 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 fn emit_end_event(
233 &mut self,
234 metadata: Option<Json>,
235 interrupted: bool,
236 background_thread: bool,
237 ) -> Option<tokio::task::JoinHandle<()>> {
238 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 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 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| ®istry.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 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 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 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;