1use std::pin::Pin;
29use std::sync::Arc;
30use std::task::{Context, Poll};
31
32use tokio_stream::Stream;
33
34use crate::api::event::{BaseEvent, MarkEvent};
35use crate::api::llm::LlmHandle;
36use crate::api::runtime::NemoRelayContextState;
37use crate::api::runtime::global_context;
38use crate::api::runtime::{ScopeStackHandle, current_scope_stack};
39use crate::api::shared::metadata_with_otel_status;
40use crate::codec::response::{AnnotatedLlmResponse, attach_estimated_cost_for_provider};
41use crate::codec::traits::LlmResponseCodec;
42use crate::error::Result;
43use crate::json::Json;
44use serde_json::Map;
45
46pub struct LlmStreamWrapper {
59 inner: Pin<Box<dyn Stream<Item = Result<Json>> + Send>>,
60 handle: LlmHandle,
61 scope_stack: ScopeStackHandle,
62 collector: Box<dyn FnMut(Json) -> Result<()> + Send>,
63 finalizer: Option<Box<dyn FnOnce() -> Json + Send>>,
64 response_codec: Option<Arc<dyn LlmResponseCodec>>,
65 metadata: Option<Json>,
66 chunk_index: u64,
67 ended: bool,
68}
69
70impl LlmStreamWrapper {
71 pub fn new(
93 inner: Pin<Box<dyn Stream<Item = Result<Json>> + Send>>,
94 handle: LlmHandle,
95 collector: Box<dyn FnMut(Json) -> Result<()> + Send>,
96 finalizer: Box<dyn FnOnce() -> Json + Send>,
97 _data: Option<Json>,
98 metadata: Option<Json>,
99 response_codec: Option<Arc<dyn LlmResponseCodec>>,
100 ) -> Self {
101 Self {
102 inner,
103 handle,
104 scope_stack: current_scope_stack(),
105 collector,
106 finalizer: Some(finalizer),
107 response_codec,
108 metadata,
109 chunk_index: 0,
110 ended: false,
111 }
112 }
113
114 pub fn scope_stack(&self) -> &ScopeStackHandle {
123 &self.scope_stack
124 }
125
126 fn finish(&mut self) {
127 if self.ended {
128 return;
129 }
130 self.ended = true;
131 self.emit_end_event(self.metadata.clone());
132 }
133
134 fn finish_with_status(&mut self, status_code: &'static str, status_message: Option<String>) {
135 if self.ended {
136 return;
137 }
138 self.ended = true;
139 let metadata =
140 metadata_with_otel_status(self.metadata.clone(), status_code, status_message);
141 self.emit_end_event(metadata);
142 }
143
144 fn emit_end_event(&mut self, metadata: Option<Json>) {
149 let aggregated = match self.finalizer.take() {
150 Some(finalizer) => finalizer(),
151 None => Json::Null,
152 };
153
154 let event_snapshot = {
155 let ss_guard = self.scope_stack.read().expect("scope stack lock poisoned");
156 let sl =
157 ss_guard.collect_scope_local_registries(|r| &r.llm_sanitize_response_guardrails);
158 let sl_subs = ss_guard.collect_scope_local_subscribers();
159 let ctx = global_context();
160 let state = ctx.read();
161 match state {
162 Ok(state) => {
163 let subscribers = state.collect_event_subscribers(&sl_subs);
164 let sanitized = state.llm_sanitize_response_chain(aggregated, &sl);
165 let data = if sanitized.is_null() {
166 self.handle.data.clone()
167 } else {
168 Some(sanitized)
169 };
170 let annotated_response: Option<Arc<AnnotatedLlmResponse>> = self
171 .response_codec
172 .as_ref()
173 .and_then(|codec| {
174 let mut decoded = codec.decode_response(data.as_ref()?).ok()?;
175 attach_estimated_cost_for_provider(
176 &mut decoded,
177 Some(&self.handle.name),
178 );
179 Some(decoded)
180 })
181 .map(Arc::new);
182 let event =
183 state.end_llm_handle(&self.handle, data, metadata, annotated_response);
184 Some((event, subscribers))
185 }
186 Err(_) => None,
187 }
188 };
189 if let Some((event, subscribers)) = event_snapshot {
190 NemoRelayContextState::emit_event(&event, &subscribers);
191 }
192 }
193
194 fn emit_chunk_mark(&self, chunk_index: u64, raw_chunk: &Json) {
196 let data = llm_chunk_mark_data(chunk_index, raw_chunk);
197 let event_snapshot = {
198 let Ok(ss_guard) = self.scope_stack.read() else {
199 return;
200 };
201 let sl_subs = ss_guard.collect_scope_local_subscribers();
202 let ctx = global_context();
203 let state = ctx.read();
204 match state {
205 Ok(state) => {
206 let subscribers = state.collect_event_subscribers(&sl_subs);
207 let event = state.create_event(MarkEvent::new(
208 BaseEvent::builder()
209 .name("llm.chunk")
210 .parent_uuid(self.handle.uuid)
211 .data(data)
212 .build(),
213 None,
214 None,
215 ));
216 Some((event, subscribers))
217 }
218 Err(_) => None,
219 }
220 };
221 if let Some((event, subscribers)) = event_snapshot {
222 NemoRelayContextState::emit_event(&event, &subscribers);
223 }
224 }
225}
226
227impl Stream for LlmStreamWrapper {
228 type Item = Result<Json>;
229
230 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
231 let this = self.get_mut();
232
233 if this.ended {
234 return Poll::Ready(None);
235 }
236
237 match this.inner.as_mut().poll_next(cx) {
239 Poll::Ready(Some(Ok(raw_chunk))) => {
240 let chunk_index = this.chunk_index;
241 this.chunk_index += 1;
242 this.emit_chunk_mark(chunk_index, &raw_chunk);
243 match (this.collector)(raw_chunk.clone()) {
245 Ok(()) => Poll::Ready(Some(Ok(raw_chunk))),
246 Err(e) => {
247 let message = e.to_string();
248 this.finish_with_status("ERROR", Some(message));
249 Poll::Ready(Some(Err(e)))
250 }
251 }
252 }
253 Poll::Ready(Some(Err(e))) => {
254 let message = e.to_string();
255 this.finish_with_status("ERROR", Some(message));
256 Poll::Ready(Some(Err(e)))
257 }
258 Poll::Ready(None) => {
259 this.finish_with_status("OK", None);
260 Poll::Ready(None)
261 }
262 Poll::Pending => Poll::Pending,
263 }
264 }
265}
266
267fn llm_chunk_mark_data(chunk_index: u64, raw_chunk: &Json) -> Json {
268 if let Some(data) = summarize_openai_chat_chunk(chunk_index, raw_chunk) {
269 return data;
270 }
271 if let Some(data) = summarize_openai_responses_chunk(chunk_index, raw_chunk) {
272 return data;
273 }
274 if let Some(data) = summarize_anthropic_messages_chunk(chunk_index, raw_chunk) {
275 return data;
276 }
277 Json::Object(base_chunk_mark_data(chunk_index, "unknown"))
278}
279
280fn base_chunk_mark_data(chunk_index: u64, provider: &str) -> Map<String, Json> {
281 let mut data = Map::new();
282 data.insert("chunk_index".into(), Json::from(chunk_index));
283 data.insert("provider".into(), Json::String(provider.to_string()));
284 data
285}
286
287fn summarize_openai_chat_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
288 let object = raw_chunk.get("object").and_then(Json::as_str);
289 let choices = raw_chunk.get("choices").and_then(Json::as_array);
290 if object != Some("chat.completion.chunk") {
291 return None;
292 }
293
294 let mut data = base_chunk_mark_data(chunk_index, "openai_chat_completions");
295 if let Some(object) = object {
296 data.insert("event_type".into(), Json::String(object.to_string()));
297 }
298 if let Some(choices) = choices {
299 let choice_indices: Vec<Json> = choices
300 .iter()
301 .filter_map(|choice| choice.get("index").and_then(Json::as_u64).map(Json::from))
302 .collect();
303 if !choice_indices.is_empty() {
304 data.insert("choice_indices".into(), Json::Array(choice_indices));
305 }
306
307 let finish_reasons: Vec<Json> = choices
308 .iter()
309 .filter_map(|choice| {
310 let reason = choice.get("finish_reason").and_then(Json::as_str)?;
311 let mut item = Map::new();
312 if let Some(index) = choice.get("index").and_then(Json::as_u64) {
313 item.insert("choice_index".into(), Json::from(index));
314 }
315 item.insert("finish_reason".into(), Json::String(reason.to_string()));
316 Some(Json::Object(item))
317 })
318 .collect();
319 if !finish_reasons.is_empty() {
320 data.insert("finish_reasons".into(), Json::Array(finish_reasons));
321 }
322 }
323 if let Some(usage) = raw_chunk.get("usage").and_then(normalize_openai_chat_usage) {
324 data.insert("usage".into(), usage);
325 }
326
327 Some(Json::Object(data))
328}
329
330fn summarize_openai_responses_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
331 let event_type = raw_chunk.get("type").and_then(Json::as_str)?;
332 if !event_type.starts_with("response.") {
333 return None;
334 }
335
336 let mut data = base_chunk_mark_data(chunk_index, "openai_responses");
337 data.insert("event_type".into(), Json::String(event_type.to_string()));
338 insert_index_fields(&mut data, raw_chunk, &["output_index", "content_index"]);
339
340 if let Some(status) = raw_chunk
341 .get("response")
342 .and_then(|response| response.get("status"))
343 .or_else(|| raw_chunk.get("status"))
344 .and_then(Json::as_str)
345 {
346 data.insert("status".into(), Json::String(status.to_string()));
347 }
348 if let Some(reason) = raw_chunk
349 .get("response")
350 .and_then(|response| response.get("incomplete_details"))
351 .and_then(|details| details.get("reason"))
352 .and_then(Json::as_str)
353 {
354 data.insert("finish_reason".into(), Json::String(reason.to_string()));
355 }
356 if let Some(usage) = raw_chunk
357 .get("usage")
358 .or_else(|| {
359 raw_chunk
360 .get("response")
361 .and_then(|response| response.get("usage"))
362 })
363 .and_then(normalize_openai_responses_usage)
364 {
365 data.insert("usage".into(), usage);
366 }
367
368 Some(Json::Object(data))
369}
370
371fn summarize_anthropic_messages_chunk(chunk_index: u64, raw_chunk: &Json) -> Option<Json> {
372 let event_type = raw_chunk.get("type").and_then(Json::as_str)?;
373 if !matches!(
374 event_type,
375 "message_start"
376 | "content_block_start"
377 | "content_block_delta"
378 | "content_block_stop"
379 | "message_delta"
380 | "message_stop"
381 | "ping"
382 ) {
383 return None;
384 }
385
386 let mut data = base_chunk_mark_data(chunk_index, "anthropic_messages");
387 data.insert("event_type".into(), Json::String(event_type.to_string()));
388 insert_index_fields(&mut data, raw_chunk, &["index"]);
389
390 if let Some(stop_reason) = raw_chunk
391 .get("delta")
392 .and_then(|delta| delta.get("stop_reason"))
393 .or_else(|| {
394 raw_chunk
395 .get("message")
396 .and_then(|message| message.get("stop_reason"))
397 })
398 .and_then(Json::as_str)
399 {
400 data.insert("stop_reason".into(), Json::String(stop_reason.to_string()));
401 }
402 if let Some(usage) = raw_chunk
403 .get("usage")
404 .or_else(|| {
405 raw_chunk
406 .get("message")
407 .and_then(|message| message.get("usage"))
408 })
409 .and_then(normalize_anthropic_usage)
410 {
411 data.insert("usage".into(), usage);
412 }
413
414 Some(Json::Object(data))
415}
416
417fn insert_index_fields(data: &mut Map<String, Json>, raw_chunk: &Json, field_names: &[&str]) {
418 let mut indices = Map::new();
419 for field_name in field_names {
420 if let Some(index) = raw_chunk.get(*field_name).and_then(Json::as_u64) {
421 indices.insert((*field_name).to_string(), Json::from(index));
422 }
423 }
424 if !indices.is_empty() {
425 data.insert("indices".into(), Json::Object(indices));
426 }
427}
428
429fn normalize_openai_chat_usage(usage: &Json) -> Option<Json> {
430 let mut normalized = Map::new();
431 insert_u64_field(&mut normalized, usage, "prompt_tokens", "prompt_tokens");
432 insert_u64_field(
433 &mut normalized,
434 usage,
435 "completion_tokens",
436 "completion_tokens",
437 );
438 insert_u64_field(&mut normalized, usage, "total_tokens", "total_tokens");
439 if let Some(cached_tokens) = usage
440 .get("prompt_tokens_details")
441 .and_then(|details| details.get("cached_tokens"))
442 .and_then(Json::as_u64)
443 {
444 normalized.insert("cache_read_tokens".into(), Json::from(cached_tokens));
445 }
446 non_empty_object(normalized)
447}
448
449fn normalize_openai_responses_usage(usage: &Json) -> Option<Json> {
450 let mut normalized = Map::new();
451 insert_u64_field(&mut normalized, usage, "input_tokens", "prompt_tokens");
452 insert_u64_field(&mut normalized, usage, "output_tokens", "completion_tokens");
453 insert_u64_field(&mut normalized, usage, "total_tokens", "total_tokens");
454 if let Some(cached_tokens) = usage
455 .get("input_tokens_details")
456 .and_then(|details| details.get("cached_tokens"))
457 .and_then(Json::as_u64)
458 {
459 normalized.insert("cache_read_tokens".into(), Json::from(cached_tokens));
460 }
461 non_empty_object(normalized)
462}
463
464fn normalize_anthropic_usage(usage: &Json) -> Option<Json> {
465 let mut normalized = Map::new();
466 let prompt_tokens = usage.get("input_tokens").and_then(Json::as_u64);
467 let completion_tokens = usage.get("output_tokens").and_then(Json::as_u64);
468 if let Some(prompt_tokens) = prompt_tokens {
469 normalized.insert("prompt_tokens".into(), Json::from(prompt_tokens));
470 }
471 if let Some(completion_tokens) = completion_tokens {
472 normalized.insert("completion_tokens".into(), Json::from(completion_tokens));
473 }
474 if let Some(total_tokens) = prompt_tokens
475 .and_then(|prompt| completion_tokens.and_then(|completion| prompt.checked_add(completion)))
476 {
477 normalized.insert("total_tokens".into(), Json::from(total_tokens));
478 }
479 insert_u64_field(
480 &mut normalized,
481 usage,
482 "cache_read_input_tokens",
483 "cache_read_tokens",
484 );
485 insert_u64_field(
486 &mut normalized,
487 usage,
488 "cache_creation_input_tokens",
489 "cache_write_tokens",
490 );
491 non_empty_object(normalized)
492}
493
494fn insert_u64_field(
495 output: &mut Map<String, Json>,
496 input: &Json,
497 input_field: &str,
498 output_field: &str,
499) {
500 if let Some(value) = input.get(input_field).and_then(Json::as_u64) {
501 output.insert(output_field.to_string(), Json::from(value));
502 }
503}
504
505fn non_empty_object(object: Map<String, Json>) -> Option<Json> {
506 if object.is_empty() {
507 None
508 } else {
509 Some(Json::Object(object))
510 }
511}
512
513impl Drop for LlmStreamWrapper {
514 fn drop(&mut self) {
515 self.finish();
516 }
517}
518
519#[cfg(test)]
520#[path = "../tests/unit/stream_tests.rs"]
521mod tests;