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::runtime::NemoRelayContextState;
use crate::api::runtime::global_context;
use crate::api::runtime::{ScopeStackHandle, current_scope_stack};
use crate::api::shared::metadata_with_otel_status;
use crate::codec::response::{AnnotatedLlmResponse, attach_estimated_cost_for_provider};
use crate::codec::traits::LlmResponseCodec;
use crate::error::Result;
use crate::json::Json;
use serde_json::Map;
pub struct LlmStreamWrapper {
inner: Pin<Box<dyn Stream<Item = Result<Json>> + Send>>,
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>>,
metadata: Option<Json>,
chunk_index: u64,
ended: bool,
}
impl LlmStreamWrapper {
pub fn new(
inner: Pin<Box<dyn Stream<Item = Result<Json>> + Send>>,
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 {
Self {
inner,
handle,
scope_stack: current_scope_stack(),
collector,
finalizer: Some(finalizer),
response_codec,
metadata,
chunk_index: 0,
ended: false,
}
}
pub fn scope_stack(&self) -> &ScopeStackHandle {
&self.scope_stack
}
fn finish(&mut self) {
if self.ended {
return;
}
self.ended = true;
self.emit_end_event(self.metadata.clone());
}
fn finish_with_status(&mut self, status_code: &'static str, status_message: Option<String>) {
if self.ended {
return;
}
self.ended = true;
let metadata =
metadata_with_otel_status(self.metadata.clone(), status_code, status_message);
self.emit_end_event(metadata);
}
fn emit_end_event(&mut self, metadata: Option<Json>) {
let aggregated = match self.finalizer.take() {
Some(finalizer) => finalizer(),
None => Json::Null,
};
let event_snapshot = {
let ss_guard = self.scope_stack.read().expect("scope stack lock poisoned");
let sl =
ss_guard.collect_scope_local_registries(|r| &r.llm_sanitize_response_guardrails);
let sl_subs = ss_guard.collect_scope_local_subscribers();
let ctx = global_context();
let state = ctx.read();
match state {
Ok(state) => {
let subscribers = state.collect_event_subscribers(&sl_subs);
let sanitized = state.llm_sanitize_response_chain(aggregated, &sl);
let data = if sanitized.is_null() {
self.handle.data.clone()
} else {
Some(sanitized)
};
let annotated_response: Option<Arc<AnnotatedLlmResponse>> = self
.response_codec
.as_ref()
.and_then(|codec| {
let mut decoded = codec.decode_response(data.as_ref()?).ok()?;
attach_estimated_cost_for_provider(
&mut decoded,
Some(&self.handle.name),
);
Some(decoded)
})
.map(Arc::new);
let event =
state.end_llm_handle(&self.handle, data, metadata, annotated_response);
Some((event, subscribers))
}
Err(_) => None,
}
};
if let Some((event, subscribers)) = event_snapshot {
NemoRelayContextState::emit_event(&event, &subscribers);
}
}
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 Ok(ss_guard) = self.scope_stack.read() else {
return;
};
let sl_subs = ss_guard.collect_scope_local_subscribers();
let ctx = global_context();
let state = ctx.read();
match state {
Ok(state) => {
let subscribers = state.collect_event_subscribers(&sl_subs);
let event = state.create_event(MarkEvent::new(
BaseEvent::builder()
.name("llm.chunk")
.parent_uuid(self.handle.uuid)
.data(data)
.build(),
None,
None,
));
Some((event, subscribers))
}
Err(_) => None,
}
};
if let Some((event, subscribers)) = event_snapshot {
NemoRelayContextState::emit_event(&event, &subscribers);
}
}
}
impl Stream for LlmStreamWrapper {
type Item = Result<Json>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
if this.ended {
return Poll::Ready(None);
}
match this.inner.as_mut().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) => {
let message = e.to_string();
this.finish_with_status("ERROR", Some(message));
Poll::Ready(Some(Err(e)))
}
}
}
Poll::Ready(Some(Err(e))) => {
let message = e.to_string();
this.finish_with_status("ERROR", Some(message));
Poll::Ready(Some(Err(e)))
}
Poll::Ready(None) => {
this.finish_with_status("OK", None);
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
}
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();
}
}
#[cfg(test)]
#[path = "../tests/unit/stream_tests.rs"]
mod tests;