@@ -22,8 +22,8 @@ use crate::api::runtime::NemoRelayContextState;
2222use crate :: api:: runtime:: global_context;
2323use crate :: api:: runtime:: state:: contextualize_stream;
2424use crate :: api:: runtime:: subscriber_dispatcher:: {
25- PendingPublication , dispatch_reserved_sanitized_event , dispatch_sanitized_event ,
26- dispatch_transformed_event, register_pending_publication,
25+ EventTransformFn , PendingPublication , dispatch_reserved_sanitized_event ,
26+ dispatch_sanitized_event , dispatch_transformed_event, register_pending_publication,
2727} ;
2828use crate :: api:: runtime:: {
2929 EventSubscriberFn , LlmCollectorFn , LlmExecutionNextFn , LlmFinalizerFn , LlmJsonStream ,
@@ -415,6 +415,7 @@ fn limit_annotated_request_history_to_current_user_turn(
415415 )
416416}
417417
418+ #[ cfg( test) ]
418419async fn emit_llm_start_with_subscribers (
419420 handle : & LlmHandle ,
420421 request : & LlmRequest ,
@@ -485,6 +486,87 @@ async fn emit_llm_start_with_subscribers(
485486 Ok ( ( ) )
486487}
487488
489+ fn queue_llm_start_with_subscribers (
490+ handle : & LlmHandle ,
491+ request : & LlmRequest ,
492+ annotated_request : Option < Arc < AnnotatedLlmRequest > > ,
493+ request_codec : Option < Arc < dyn LlmCodec > > ,
494+ subscribers : & [ EventSubscriberFn ] ,
495+ ) -> Result < ( ) > {
496+ ensure_runtime_owner ( ) ?;
497+ let scope_stack = handle. captured_scope_stack ( ) . clone ( ) ;
498+ let ( entries, agent_is_fresh, full_payloads_enabled) = {
499+ let mut scope_guard = scope_stack. write ( ) . expect ( "scope stack lock poisoned" ) ;
500+ let scope_locals = scope_guard. collect_scope_local_registries ( |registries| {
501+ & registries. llm_sanitize_request_guardrails
502+ } ) ;
503+ let context = global_context ( ) ;
504+ let state = context
505+ . read ( )
506+ . map_err ( |error| FlowError :: Internal ( error. to_string ( ) ) ) ?;
507+ let entries = state. llm_sanitize_request_entries ( & scope_locals) ;
508+ let full_payloads_enabled = state. observability_full_payloads_enabled ;
509+ drop ( state) ;
510+ let agent_is_fresh = scope_guard. take_agent_freshness ( handle. parent_uuid ) ;
511+ ( entries, agent_is_fresh, full_payloads_enabled)
512+ } ;
513+ let observable_request = remove_observability_credential_headers ( request. clone ( ) ) ;
514+ let queued_handle = handle. clone ( ) ;
515+ let event = {
516+ let context = global_context ( ) ;
517+ let state = context
518+ . read ( )
519+ . map_err ( |error| FlowError :: Internal ( error. to_string ( ) ) ) ?;
520+ state. build_llm_start_event ( handle, None , None )
521+ } ;
522+ let event_sanitizers = snapshot_event_sanitizers ( & event, & scope_stack) . unwrap_or_default ( ) ;
523+ dispatch_transformed_event (
524+ event,
525+ Box :: new ( move |event| {
526+ Box :: pin ( async move {
527+ let mut sanitized_request =
528+ NemoRelayContextState :: llm_sanitize_request_snapshot_chain (
529+ observable_request. clone ( ) ,
530+ LlmSanitizeRequestContext :: for_request_codec ( request_codec. clone ( ) ) ,
531+ & entries,
532+ )
533+ . await ;
534+ let request_changed = sanitized_request
535+ . as_ref ( )
536+ . is_some_and ( |sanitized| sanitized != & observable_request) ;
537+ let mut annotation = match ( sanitized_request. as_ref ( ) , request_codec. as_deref ( ) ) {
538+ ( Some ( sanitized) , Some ( codec) ) if request_changed => {
539+ codec. decode ( sanitized) . ok ( ) . map ( Arc :: new)
540+ }
541+ ( Some ( _) , _) if !request_changed => annotated_request,
542+ _ => None ,
543+ } ;
544+ if !full_payloads_enabled
545+ && !agent_is_fresh
546+ && let Some ( sanitized_request) = sanitized_request. as_mut ( )
547+ {
548+ project_llm_request_to_current_user_turn (
549+ sanitized_request,
550+ & mut annotation,
551+ request_codec. as_deref ( ) ,
552+ ) ;
553+ }
554+ let input = sanitized_request
555+ . as_ref ( )
556+ . and_then ( |request| serde_json:: to_value ( request) . ok ( ) ) ;
557+ global_context ( )
558+ . read ( )
559+ . map ( |state| state. build_llm_start_event ( & queued_handle, input, annotation) )
560+ . unwrap_or ( event)
561+ } )
562+ } ) ,
563+ event_sanitizers,
564+ subscribers,
565+ scope_stack,
566+ ) ;
567+ Ok ( ( ) )
568+ }
569+
488570fn remove_observability_credential_headers ( mut request : LlmRequest ) -> LlmRequest {
489571 request. headers . retain ( |name, _| {
490572 !OBSERVABILITY_CREDENTIAL_HEADERS
@@ -841,7 +923,6 @@ pub fn llm_call(params: LlmCallParams<'_>) -> Result<LlmHandle> {
841923
842924#[ derive( Clone , Copy ) ]
843925struct LlmCallEndBehavior {
844- response_codec_errors_fatal : bool ,
845926 attach_estimated_cost : bool ,
846927}
847928
@@ -851,6 +932,18 @@ struct LlmEndPayload {
851932 decode_error : Option < FlowError > ,
852933}
853934
935+ /// Queue a provisional LLM END event and replace its observability-only
936+ /// payload on the serial publication path before event sanitizers run.
937+ fn queue_llm_end_event (
938+ event : Event ,
939+ transform : EventTransformFn ,
940+ subscribers : & [ EventSubscriberFn ] ,
941+ scope_stack : ScopeStackHandle ,
942+ ) {
943+ let event_sanitizers = snapshot_event_sanitizers ( & event, & scope_stack) . unwrap_or_default ( ) ;
944+ dispatch_transformed_event ( event, transform, event_sanitizers, subscribers, scope_stack) ;
945+ }
946+
854947async fn build_llm_end_payload (
855948 handle : & LlmHandle ,
856949 response : Json ,
@@ -1007,7 +1100,6 @@ pub fn llm_call_end(params: LlmCallEndParams<'_>) -> Result<()> {
10071100 response_codec,
10081101 & entries,
10091102 LlmCallEndBehavior {
1010- response_codec_errors_fatal : false ,
10111103 attach_estimated_cost : false ,
10121104 } ,
10131105 )
@@ -1076,41 +1168,65 @@ async fn llm_call_end_with_behavior(
10761168 ( entries, subscribers)
10771169 } ;
10781170 handle. optimization_recorder . close_for_finalization ( None ) ;
1079- emit_optimization_marks ( handle, & subscribers) . await ;
1080- let payload = build_llm_end_payload (
1081- handle,
1082- response,
1083- data,
1084- annotated_response,
1085- response_codec,
1086- & entries,
1087- behavior,
1088- )
1089- . await ;
1171+ enqueue_optimization_marks ( handle, & subscribers) ;
1172+ let queued_handle = handle. clone ( ) ;
10901173 let event = {
10911174 let context = global_context ( ) ;
10921175 let state = context
10931176 . read ( )
10941177 . map_err ( |error| FlowError :: Internal ( error. to_string ( ) ) ) ?;
1095- let end_metadata = metadata_with_otel_status ( metadata, "OK" , None ) ;
1178+ let end_metadata = metadata_with_otel_status ( metadata. clone ( ) , "OK" , None ) ;
10961179 state. build_llm_end_event (
10971180 EndLlmHandleParams :: builder ( )
10981181 . handle ( handle)
1099- . data_opt ( payload . data )
1182+ . data ( Json :: Null )
11001183 . metadata_opt ( end_metadata)
1101- . annotated_response_opt ( payload. annotated_response )
11021184 . timestamp_opt ( timestamp)
11031185 . build ( ) ,
11041186 )
11051187 } ;
1106- queue_sanitized_event_with_scope_stack ( event, & subscribers, handle. captured_scope_stack ( ) ) ;
1107- if let Some ( error) = payload. decode_error
1108- && behavior. response_codec_errors_fatal
1109- {
1110- Err ( error)
1111- } else {
1112- Ok ( ( ) )
1113- }
1188+ let scope_stack = handle. captured_scope_stack ( ) . clone ( ) ;
1189+ queue_llm_end_event (
1190+ event,
1191+ Box :: new ( move |event| {
1192+ Box :: pin ( async move {
1193+ let payload = build_llm_end_payload (
1194+ & queued_handle,
1195+ response,
1196+ data,
1197+ annotated_response,
1198+ response_codec,
1199+ & entries,
1200+ behavior,
1201+ )
1202+ . await ;
1203+ if let Some ( error) = payload. decode_error {
1204+ log:: error!(
1205+ target: "nemo_relay.runtime" ,
1206+ event = "managed_llm_response_codec_failed" ;
1207+ "Managed LLM response annotation failed during queued publication: {error}"
1208+ ) ;
1209+ }
1210+ let context = global_context ( ) ;
1211+ let Ok ( state) = context. read ( ) else {
1212+ return event;
1213+ } ;
1214+ let end_metadata = metadata_with_otel_status ( metadata, "OK" , None ) ;
1215+ state. build_llm_end_event (
1216+ EndLlmHandleParams :: builder ( )
1217+ . handle ( & queued_handle)
1218+ . data_opt ( payload. data )
1219+ . metadata_opt ( end_metadata)
1220+ . annotated_response_opt ( payload. annotated_response )
1221+ . timestamp_opt ( timestamp)
1222+ . build ( ) ,
1223+ )
1224+ } )
1225+ } ) ,
1226+ & subscribers,
1227+ scope_stack,
1228+ ) ;
1229+ Ok ( ( ) )
11141230}
11151231
11161232#[ cfg( test) ]
@@ -1178,46 +1294,70 @@ async fn emit_llm_end_without_output(
11781294 let entries = state. llm_sanitize_response_entries ( & scope_locals) ;
11791295 ( entries, subscribers)
11801296 } ;
1181- let had_fallback_data = handle. data . is_some ( ) ;
1182- let data = if let Some ( data) = handle. data . clone ( ) {
1183- NemoRelayContextState :: llm_sanitize_response_snapshot_chain (
1184- data,
1185- LlmSanitizeResponseContext :: for_response_codec ( response_codec) ,
1186- & entries,
1187- )
1188- . await
1189- } else {
1190- None
1191- } ;
1192- let annotation_omitted =
1193- ( had_fallback_data && data. is_none ( ) ) || data. as_ref ( ) . is_some_and ( Json :: is_null) ;
11941297 handle. optimization_recorder . close_for_finalization ( None ) ;
1195- emit_optimization_marks ( handle, & subscribers) . await ;
1196- let pricing = crate :: codec:: response:: active_pricing_resolver ( ) ;
1197- let annotated_response = ( !annotation_omitted)
1198- . then ( || {
1199- finalize_optimization_summary (
1200- & handle. optimization_recorder ,
1201- None ,
1202- handle. model_name . as_deref ( ) ,
1203- & pricing,
1204- )
1205- } )
1206- . flatten ( )
1207- . map ( |summary| {
1208- Arc :: new ( AnnotatedLlmResponse {
1209- optimization_summary : Some ( summary) ,
1210- ..AnnotatedLlmResponse :: default ( )
1211- } )
1212- } ) ;
1298+ enqueue_optimization_marks ( handle, & subscribers) ;
1299+ let queued_handle = handle. clone ( ) ;
1300+ let fallback_data = handle. data . clone ( ) ;
12131301 let event = {
12141302 let context = global_context ( ) ;
12151303 let state = context
12161304 . read ( )
12171305 . map_err ( |error| FlowError :: Internal ( error. to_string ( ) ) ) ?;
1218- state. end_llm_handle ( handle, data, metadata, annotated_response)
1306+ state. build_llm_end_event (
1307+ EndLlmHandleParams :: builder ( )
1308+ . handle ( handle)
1309+ . data ( Json :: Null )
1310+ . metadata_opt ( metadata. clone ( ) )
1311+ . build ( ) ,
1312+ )
12191313 } ;
1220- queue_sanitized_event_with_scope_stack ( event, & subscribers, handle. captured_scope_stack ( ) ) ;
1314+ let scope_stack = handle. captured_scope_stack ( ) . clone ( ) ;
1315+ queue_llm_end_event (
1316+ event,
1317+ Box :: new ( move |event| {
1318+ Box :: pin ( async move {
1319+ let had_fallback_data = fallback_data. is_some ( ) ;
1320+ let data = match fallback_data {
1321+ Some ( data) => {
1322+ NemoRelayContextState :: llm_sanitize_response_snapshot_chain (
1323+ data,
1324+ LlmSanitizeResponseContext :: for_response_codec ( response_codec) ,
1325+ & entries,
1326+ )
1327+ . await
1328+ }
1329+ None => None ,
1330+ } ;
1331+ let annotation_omitted = ( had_fallback_data && data. is_none ( ) )
1332+ || data. as_ref ( ) . is_some_and ( Json :: is_null) ;
1333+ let pricing = crate :: codec:: response:: active_pricing_resolver ( ) ;
1334+ let annotated_response = ( !annotation_omitted)
1335+ . then ( || {
1336+ finalize_optimization_summary (
1337+ & queued_handle. optimization_recorder ,
1338+ None ,
1339+ queued_handle. model_name . as_deref ( ) ,
1340+ & pricing,
1341+ )
1342+ } )
1343+ . flatten ( )
1344+ . map ( |summary| {
1345+ Arc :: new ( AnnotatedLlmResponse {
1346+ optimization_summary : Some ( summary) ,
1347+ ..AnnotatedLlmResponse :: default ( )
1348+ } )
1349+ } ) ;
1350+ global_context ( )
1351+ . read ( )
1352+ . map ( |state| {
1353+ state. end_llm_handle ( & queued_handle, data, metadata, annotated_response)
1354+ } )
1355+ . unwrap_or ( event)
1356+ } )
1357+ } ) ,
1358+ & subscribers,
1359+ scope_stack,
1360+ ) ;
12211361 Ok ( ( ) )
12221362}
12231363
@@ -1470,14 +1610,13 @@ pub async fn llm_call_execute(params: LlmCallExecuteParams) -> Result<Json> {
14701610 let scope_guard = scope_stack. read ( ) . expect ( "scope stack lock poisoned" ) ;
14711611 snapshot_event_subscribers ( scope_guard. collect_scope_local_subscribers ( ) ) ?
14721612 } ;
1473- emit_llm_start_with_subscribers (
1613+ queue_llm_start_with_subscribers (
14741614 & handle,
14751615 & intercepted_request,
14761616 annotated_request. clone ( ) ,
14771617 request_codec. clone ( ) ,
14781618 & lifecycle_subscribers,
1479- )
1480- . await ?;
1619+ ) ?;
14811620 emit_pending_request_marks ( & handle, pending_marks, & lifecycle_subscribers) . await ?;
14821621 handle
14831622 . optimization_recorder
@@ -1523,7 +1662,6 @@ pub async fn llm_call_execute(params: LlmCallExecuteParams) -> Result<Json> {
15231662 . response_codec_opt ( response_codec)
15241663 . build ( ) ,
15251664 LlmCallEndBehavior {
1526- response_codec_errors_fatal : false ,
15271665 attach_estimated_cost : true ,
15281666 } ,
15291667 Some ( & lifecycle_subscribers) ,
@@ -1679,14 +1817,13 @@ pub async fn llm_stream_call_execute(params: LlmStreamCallExecuteParams) -> Resu
16791817 let scope_guard = scope_stack. read ( ) . expect ( "scope stack lock poisoned" ) ;
16801818 snapshot_event_subscribers ( scope_guard. collect_scope_local_subscribers ( ) ) ?
16811819 } ;
1682- emit_llm_start_with_subscribers (
1820+ queue_llm_start_with_subscribers (
16831821 & handle,
16841822 & intercepted_request,
16851823 annotated_request,
16861824 request_codec. clone ( ) ,
16871825 & lifecycle_subscribers,
1688- )
1689- . await ?;
1826+ ) ?;
16901827 emit_pending_request_marks ( & handle, pending_marks, & lifecycle_subscribers) . await ?;
16911828 handle
16921829 . optimization_recorder
0 commit comments