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