use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio_stream::Stream;
use crate::api::event::{BaseEvent, MarkEvent};
use crate::api::llm::LlmHandle;
use crate::api::llm::emit_reserved_optimization_marks;
use crate::api::optimization::finalize_optimization_summary;
use crate::api::registry::Guardrail;
use crate::api::runtime::NemoRelayContextState;
use crate::api::runtime::global_context;
use crate::api::runtime::subscriber_dispatcher;
use crate::api::runtime::{
EventSubscriberFn, LlmJsonStream, LlmStreamInner, ScopeStackHandle, TASK_SCOPE_STACK,
current_scope_stack,
};
use crate::api::runtime::{LlmSanitizeResponseContext, LlmSanitizeResponseFn};
use crate::api::shared::{
metadata_with_otel_error, metadata_with_otel_status, snapshot_event_sanitizers,
};
use crate::codec::response::{AnnotatedLlmResponse, attach_estimated_cost_for_provider};
use crate::codec::traits::LlmResponseCodec;
use crate::error::{FlowError, Result};
use crate::json::Json;
use serde_json::Map;
pub struct LlmStreamWrapper {
inner: LlmJsonStream,
handle: LlmHandle,
scope_stack: ScopeStackHandle,
collector: Box<dyn FnMut(Json) -> Result<()> + Send>,
finalizer: Option<Box<dyn FnOnce() -> Json + Send>>,
response_codec: Option<Arc<dyn LlmResponseCodec>>,
sanitize_context: LlmSanitizeResponseContext,
metadata: Option<Json>,
subscribers: Vec<EventSubscriberFn>,
chunk_index: u64,
ended: bool,
close_result: Option<Result<()>>,
finalization: Option<tokio::task::JoinHandle<()>>,
terminal_result: Option<Result<Json>>,
}
impl LlmStreamWrapper {
pub fn new(
inner: LlmJsonStream,
handle: LlmHandle,
collector: Box<dyn FnMut(Json) -> Result<()> + Send>,
finalizer: Box<dyn FnOnce() -> Json + Send>,
_data: Option<Json>,
metadata: Option<Json>,
response_codec: Option<Arc<dyn LlmResponseCodec>>,
) -> Self {
let subscribers = {
let scope_stack = current_scope_stack();
let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
let scope_subscribers = scope_guard.collect_scope_local_subscribers();
let context = global_context();
context
.read()
.map(|state| state.collect_event_subscribers(&scope_subscribers))
.unwrap_or_default()
};
Self::new_managed(
inner,
handle,
collector,
finalizer,
metadata,
response_codec,
subscribers,
)
}
pub(crate) fn new_managed(
inner: LlmJsonStream,
handle: LlmHandle,
collector: Box<dyn FnMut(Json) -> Result<()> + Send>,
finalizer: Box<dyn FnOnce() -> Json + Send>,
metadata: Option<Json>,
response_codec: Option<Arc<dyn LlmResponseCodec>>,
subscribers: Vec<EventSubscriberFn>,
) -> Self {
let scope_stack = handle.captured_scope_stack().clone();
let sanitize_context =
LlmSanitizeResponseContext::for_response_codec(response_codec.clone());
Self {
inner,
handle,
scope_stack,
collector,
finalizer: Some(finalizer),
response_codec,
sanitize_context,
metadata,
subscribers,
chunk_index: 0,
ended: false,
close_result: None,
finalization: None,
terminal_result: None,
}
}
pub fn scope_stack(&self) -> &ScopeStackHandle {
&self.scope_stack
}
fn finish(&mut self, background_thread: bool) {
if self.ended {
return;
}
self.ended = true;
let metadata = metadata_with_otel_status(
self.metadata.clone(),
"ERROR",
Some("stream dropped before clean completion".to_string()),
);
self.handle
.optimization_recorder
.close_for_finalization(None);
self.finalization = self.emit_end_event(metadata, true, background_thread);
}
fn finish_with_status(
&mut self,
status_code: &'static str,
status_message: Option<String>,
interrupted: bool,
) {
if self.ended {
return;
}
self.ended = true;
self.inner.terminalize();
let metadata =
metadata_with_otel_status(self.metadata.clone(), status_code, status_message);
self.finalization = self.emit_end_event(metadata, interrupted, false);
}
fn finish_with_error(&mut self, error: &FlowError, interrupted: bool) {
if self.ended {
return;
}
self.ended = true;
self.inner.terminalize();
let metadata = metadata_with_otel_error(self.metadata.clone(), error);
self.finalization = self.emit_end_event(metadata, interrupted, false);
}
fn emit_end_event(
&mut self,
metadata: Option<Json>,
interrupted: bool,
background_thread: bool,
) -> Option<tokio::task::JoinHandle<()>> {
let publication_barrier = subscriber_dispatcher::register_async_publication();
let aggregated = match self.finalizer.take() {
Some(finalizer) => finalizer(),
None => Json::Null,
};
let response_was_null_without_fallback = aggregated.is_null() && self.handle.data.is_none();
let response = if aggregated.is_null() {
self.handle.data.clone().unwrap_or(aggregated)
} else {
aggregated
};
let (entries, sanitizer_snapshot_failed) =
snapshot_stream_end_sanitizers(&self.scope_stack);
let handle = self.handle.clone();
let scope_stack = self.scope_stack.clone();
let finalization_scope_stack = scope_stack.clone();
let subscribers = self.subscribers.clone();
let response_codec = self.response_codec.clone();
let sanitize_context = self.sanitize_context.clone();
let finalize = async move {
let sanitized = (!sanitizer_snapshot_failed).then(|| {
NemoRelayContextState::llm_sanitize_response_snapshot_chain(
response,
sanitize_context,
&entries,
)
});
let sanitized = match sanitized {
Some(sanitized) => sanitized.await,
None => None,
};
let data = match sanitized {
Some(response) if response_was_null_without_fallback && response.is_null() => None,
response => response,
};
let annotation_omitted = data.as_ref().is_none_or(Json::is_null);
let mut annotated_response: Option<AnnotatedLlmResponse> = (!annotation_omitted)
.then(|| {
data.as_ref().and_then(|response| {
response_codec.as_ref().and_then(|codec| {
let mut decoded = codec.decode_response(response).ok()?;
attach_estimated_cost_for_provider(&mut decoded, Some(&handle.name));
Some(decoded)
})
})
})
.flatten();
let interruption = (interrupted
&& !has_authoritative_final_usage(annotated_response.as_ref()))
.then_some("stream_interrupted");
handle
.optimization_recorder
.close_for_finalization(interruption);
emit_reserved_optimization_marks(&handle, &subscribers).await;
let pricing = crate::codec::response::active_pricing_resolver();
let summary = finalize_optimization_summary(
&handle.optimization_recorder,
annotated_response.as_mut(),
handle.model_name.as_deref(),
&pricing,
);
if !annotation_omitted
&& annotated_response.is_none()
&& let Some(summary) = summary
{
annotated_response = Some(AnnotatedLlmResponse {
optimization_summary: Some(summary),
..AnnotatedLlmResponse::default()
});
}
let annotated_response = annotated_response.map(Arc::new);
let event_snapshot = {
let ctx = global_context();
let state = ctx.read();
match state {
Ok(state) => {
Some(state.end_llm_handle(&handle, data, metadata, annotated_response))
}
Err(_) => None,
}
};
if let Some(event) = event_snapshot {
let sanitizers =
snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
let _ = subscriber_dispatcher::dispatch_reserved_sanitized_event(
event,
sanitizers,
&subscribers,
scope_stack.clone(),
);
}
};
let finalize = TASK_SCOPE_STACK.scope(finalization_scope_stack, finalize);
let publication_context = subscriber_dispatcher::capture_publication_context();
let finalize = subscriber_dispatcher::with_task_publication_context(
publication_context,
subscriber_dispatcher::with_async_publication_context(publication_barrier, finalize),
);
if background_thread {
let _ = subscriber_dispatcher::spawn_background_publication(finalize);
return None;
}
match tokio::runtime::Handle::try_current() {
Ok(handle) => Some(handle.spawn(finalize)),
Err(_) => {
let _ = subscriber_dispatcher::spawn_background_publication(finalize);
None
}
}
}
fn emit_chunk_mark(&self, chunk_index: u64, raw_chunk: &Json) {
let data = llm_chunk_mark_data(chunk_index, raw_chunk);
let event_snapshot = {
let ctx = global_context();
let state = ctx.read();
match state {
Ok(state) => {
let event = state.create_event(MarkEvent::new(
BaseEvent::builder()
.name("llm.chunk")
.parent_uuid(self.handle.uuid)
.data(data)
.build(),
None,
None,
));
Some(event)
}
Err(_) => None,
}
};
if let Some(event) = event_snapshot {
let sanitizers =
snapshot_event_sanitizers(&event, &self.scope_stack).unwrap_or_default();
let _ = subscriber_dispatcher::dispatch_sanitized_event(
event,
sanitizers,
&self.subscribers,
self.scope_stack.clone(),
);
}
}
}
fn snapshot_stream_end_sanitizers(
scope_stack: &ScopeStackHandle,
) -> (Vec<Guardrail<LlmSanitizeResponseFn>>, bool) {
let entries = scope_stack.read().ok().and_then(|scope_guard| {
let scope_locals = scope_guard
.collect_scope_local_registries(|registry| ®istry.llm_sanitize_response_guardrails);
global_context()
.read()
.ok()
.map(|state| state.llm_sanitize_response_entries(&scope_locals))
});
match entries {
Some(entries) => (entries, false),
None => {
log::error!(
target: "nemo_relay.runtime",
event = "stream_end_sanitizer_snapshot_failed";
"LLM stream END sanitizer snapshot failed; omitting the observability payload"
);
(Vec::new(), true)
}
}
}
impl Stream for LlmStreamWrapper {
type Item = Result<Json>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.as_mut().get_mut();
if let Some(finalization) = this.finalization.as_mut() {
return match Pin::new(finalization).poll(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Ok(())) => {
this.finalization = None;
match this.terminal_result.take() {
Some(result) => Poll::Ready(Some(result)),
None => Poll::Ready(None),
}
}
Poll::Ready(Err(error)) => {
this.finalization = None;
Poll::Ready(Some(Err(FlowError::Internal(format!(
"stream finalization task failed: {error}"
)))))
}
};
}
if this.ended {
return match this.terminal_result.take() {
Some(result) => Poll::Ready(Some(result)),
None => Poll::Ready(None),
};
}
match Pin::new(&mut this.inner).poll_next(cx) {
Poll::Ready(Some(Ok(raw_chunk))) => {
let chunk_index = this.chunk_index;
this.chunk_index += 1;
this.emit_chunk_mark(chunk_index, &raw_chunk);
match (this.collector)(raw_chunk.clone()) {
Ok(()) => Poll::Ready(Some(Ok(raw_chunk))),
Err(e) => {
this.finish_with_error(&e, true);
this.terminal_result = Some(Err(e));
self.poll_next(cx)
}
}
}
Poll::Ready(Some(Err(e))) => {
this.finish_with_error(&e, true);
this.terminal_result = Some(Err(e));
self.poll_next(cx)
}
Poll::Ready(None) => {
this.finish_with_status("OK", None, false);
self.poll_next(cx)
}
Poll::Pending => Poll::Pending,
}
}
}
impl LlmStreamInner for LlmStreamWrapper {
fn close(self: Pin<&mut Self>) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
let this = self.get_mut();
Box::pin(async move {
if let Some(result) = &this.close_result {
return result.clone();
}
let result = this.inner.close().await;
this.finish(false);
if let Some(finalization) = this.finalization.take() {
finalization.await.map_err(|error| {
FlowError::Internal(format!("stream finalization task failed: {error}"))
})?;
}
this.close_result = Some(result.clone());
this.close_result
.as_ref()
.expect("close result was just stored")
.clone()
})
}
}
fn has_authoritative_final_usage(response: Option<&AnnotatedLlmResponse>) -> bool {
response.is_some_and(|response| {
response.finish_reason.is_some()
&& response.usage.as_ref().is_some_and(|usage| {
usage.total_tokens.is_some()
|| (usage.prompt_tokens.is_some() && usage.completion_tokens.is_some())
})
})
}
fn llm_chunk_mark_data(chunk_index: u64, raw_chunk: &Json) -> Json {
if let Some(data) = summarize_openai_chat_chunk(chunk_index, raw_chunk) {
return data;
}
if let Some(data) = summarize_openai_responses_chunk(chunk_index, raw_chunk) {
return data;
}
if let Some(data) = summarize_anthropic_messages_chunk(chunk_index, raw_chunk) {
return data;
}
Json::Object(base_chunk_mark_data(chunk_index, "unknown"))
}
fn base_chunk_mark_data(chunk_index: u64, provider: &str) -> Map<String, Json> {
let mut data = Map::new();
data.insert("chunk_index".into(), Json::from(chunk_index));
data.insert("provider".into(), Json::String(provider.to_string()));
data
}
fn summarize_openai_chat_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
let object = raw_chunk.get("object").and_then(Json::as_str);
let choices = raw_chunk.get("choices").and_then(Json::as_array);
if object != Some("chat.completion.chunk") {
return None;
}
let mut data = base_chunk_mark_data(chunk_index, "openai_chat_completions");
if let Some(object) = object {
data.insert("event_type".into(), Json::String(object.to_string()));
}
if let Some(choices) = choices {
let choice_indices: Vec<Json> = choices
.iter()
.filter_map(|choice| choice.get("index").and_then(Json::as_u64).map(Json::from))
.collect();
if !choice_indices.is_empty() {
data.insert("choice_indices".into(), Json::Array(choice_indices));
}
let finish_reasons: Vec<Json> = choices
.iter()
.filter_map(|choice| {
let reason = choice.get("finish_reason").and_then(Json::as_str)?;
let mut item = Map::new();
if let Some(index) = choice.get("index").and_then(Json::as_u64) {
item.insert("choice_index".into(), Json::from(index));
}
item.insert("finish_reason".into(), Json::String(reason.to_string()));
Some(Json::Object(item))
})
.collect();
if !finish_reasons.is_empty() {
data.insert("finish_reasons".into(), Json::Array(finish_reasons));
}
}
if let Some(usage) = raw_chunk.get("usage").and_then(normalize_openai_chat_usage) {
data.insert("usage".into(), usage);
}
Some(Json::Object(data))
}
fn summarize_openai_responses_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
let event_type = raw_chunk.get("type").and_then(Json::as_str)?;
if !event_type.starts_with("response.") {
return None;
}
let mut data = base_chunk_mark_data(chunk_index, "openai_responses");
data.insert("event_type".into(), Json::String(event_type.to_string()));
insert_index_fields(&mut data, raw_chunk, &["output_index", "content_index"]);
if let Some(status) = raw_chunk
.get("response")
.and_then(|response| response.get("status"))
.or_else(|| raw_chunk.get("status"))
.and_then(Json::as_str)
{
data.insert("status".into(), Json::String(status.to_string()));
}
if let Some(reason) = raw_chunk
.get("response")
.and_then(|response| response.get("incomplete_details"))
.and_then(|details| details.get("reason"))
.and_then(Json::as_str)
{
data.insert("finish_reason".into(), Json::String(reason.to_string()));
}
if let Some(usage) = raw_chunk
.get("usage")
.or_else(|| {
raw_chunk
.get("response")
.and_then(|response| response.get("usage"))
})
.and_then(normalize_openai_responses_usage)
{
data.insert("usage".into(), usage);
}
Some(Json::Object(data))
}
fn summarize_anthropic_messages_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
let event_type = raw_chunk.get("type").and_then(Json::as_str)?;
if !matches!(
event_type,
"message_start"
| "content_block_start"
| "content_block_delta"
| "content_block_stop"
| "message_delta"
| "message_stop"
| "ping"
) {
return None;
}
let mut data = base_chunk_mark_data(chunk_index, "anthropic_messages");
data.insert("event_type".into(), Json::String(event_type.to_string()));
insert_index_fields(&mut data, raw_chunk, &["index"]);
if let Some(stop_reason) = raw_chunk
.get("delta")
.and_then(|delta| delta.get("stop_reason"))
.or_else(|| {
raw_chunk
.get("message")
.and_then(|message| message.get("stop_reason"))
})
.and_then(Json::as_str)
{
data.insert("stop_reason".into(), Json::String(stop_reason.to_string()));
}
if let Some(usage) = raw_chunk
.get("usage")
.or_else(|| {
raw_chunk
.get("message")
.and_then(|message| message.get("usage"))
})
.and_then(normalize_anthropic_usage)
{
data.insert("usage".into(), usage);
}
Some(Json::Object(data))
}
fn insert_index_fields(data: &mut Map<String, Json>, raw_chunk: &Json, field_names: &[&str]) {
let mut indices = Map::new();
for field_name in field_names {
if let Some(index) = raw_chunk.get(*field_name).and_then(Json::as_u64) {
indices.insert((*field_name).to_string(), Json::from(index));
}
}
if !indices.is_empty() {
data.insert("indices".into(), Json::Object(indices));
}
}
fn normalize_openai_chat_usage(usage: &Json) -> Option<Json> {
let mut normalized = Map::new();
insert_u64_field(&mut normalized, usage, "prompt_tokens", "prompt_tokens");
insert_u64_field(
&mut normalized,
usage,
"completion_tokens",
"completion_tokens",
);
insert_u64_field(&mut normalized, usage, "total_tokens", "total_tokens");
if let Some(cached_tokens) = usage
.get("prompt_tokens_details")
.and_then(|details| details.get("cached_tokens"))
.and_then(Json::as_u64)
{
normalized.insert("cache_read_tokens".into(), Json::from(cached_tokens));
}
non_empty_object(normalized)
}
fn normalize_openai_responses_usage(usage: &Json) -> Option<Json> {
let mut normalized = Map::new();
insert_u64_field(&mut normalized, usage, "input_tokens", "prompt_tokens");
insert_u64_field(&mut normalized, usage, "output_tokens", "completion_tokens");
insert_u64_field(&mut normalized, usage, "total_tokens", "total_tokens");
if let Some(cached_tokens) = usage
.get("input_tokens_details")
.and_then(|details| details.get("cached_tokens"))
.and_then(Json::as_u64)
{
normalized.insert("cache_read_tokens".into(), Json::from(cached_tokens));
}
non_empty_object(normalized)
}
fn normalize_anthropic_usage(usage: &Json) -> Option<Json> {
let mut normalized = Map::new();
let prompt_tokens = usage.get("input_tokens").and_then(Json::as_u64);
let completion_tokens = usage.get("output_tokens").and_then(Json::as_u64);
if let Some(prompt_tokens) = prompt_tokens {
normalized.insert("prompt_tokens".into(), Json::from(prompt_tokens));
}
if let Some(completion_tokens) = completion_tokens {
normalized.insert("completion_tokens".into(), Json::from(completion_tokens));
}
if let Some(total_tokens) = prompt_tokens
.and_then(|prompt| completion_tokens.and_then(|completion| prompt.checked_add(completion)))
{
normalized.insert("total_tokens".into(), Json::from(total_tokens));
}
insert_u64_field(
&mut normalized,
usage,
"cache_read_input_tokens",
"cache_read_tokens",
);
insert_u64_field(
&mut normalized,
usage,
"cache_creation_input_tokens",
"cache_write_tokens",
);
non_empty_object(normalized)
}
fn insert_u64_field(
output: &mut Map<String, Json>,
input: &Json,
input_field: &str,
output_field: &str,
) {
if let Some(value) = input.get(input_field).and_then(Json::as_u64) {
output.insert(output_field.to_string(), Json::from(value));
}
}
fn non_empty_object(object: Map<String, Json>) -> Option<Json> {
if object.is_empty() {
None
} else {
Some(Json::Object(object))
}
}
impl Drop for LlmStreamWrapper {
fn drop(&mut self) {
self.finish(true);
}
}
#[cfg(test)]
#[path = "../tests/unit/stream_tests.rs"]
mod tests;