Skip to main content

nemo_relay/api/
llm.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::future::Future;
5use std::sync::Arc;
6
7use chrono::{DateTime, TimeDelta, Utc};
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use typed_builder::TypedBuilder;
11use uuid::Uuid;
12
13use crate::api::event::{
14    BaseEvent, CategoryProfile, DataSchema, Event, EventCategory, MarkEvent, PendingMarkSpec,
15};
16use crate::api::optimization::{
17    LlmOptimizationRecorder, finalize_optimization_summary, scope_llm_optimization_recorder,
18};
19#[cfg(test)]
20use crate::api::runtime::LlmCodecIdentity;
21use crate::api::runtime::NemoRelayContextState;
22use crate::api::runtime::global_context;
23use crate::api::runtime::state::contextualize_stream;
24use crate::api::runtime::subscriber_dispatcher::{
25    PendingPublication, dispatch_reserved_sanitized_event, dispatch_sanitized_event,
26    dispatch_transformed_event, register_pending_publication,
27};
28use crate::api::runtime::{
29    EventSubscriberFn, LlmCollectorFn, LlmExecutionNextFn, LlmFinalizerFn, LlmJsonStream,
30    LlmSanitizeRequestContext, LlmSanitizeResponseContext, LlmStreamExecutionNextFn,
31    MiddlewareContinuationContext, with_active_event_uuid,
32};
33use crate::api::runtime::{ScopeStackHandle, current_scope_stack};
34use crate::api::scope::event;
35use crate::api::scope::{EmitMarkEventParams, ScopeHandle};
36use crate::api::shared::{
37    ensure_runtime_owner, inject_dynamo_session_ids, metadata_with_otel_error,
38    metadata_with_otel_status, resolve_parent_uuid, run_request_intercepts_with_codec_and_recorder,
39    snapshot_event_sanitizers, snapshot_event_subscribers,
40};
41use crate::codec::request::{AnnotatedLlmRequest, Message};
42use crate::codec::response::{AnnotatedLlmResponse, attach_estimated_cost_for_provider};
43use crate::codec::traits::{LlmCodec, LlmResponseCodec};
44use crate::error::{FlowError, Result};
45use crate::json::Json;
46use crate::stream::LlmStreamWrapper;
47
48pub use nemo_relay_types::api::llm::{
49    LLM_REQUEST_INTERCEPT_OUTCOME_SCHEMA, LlmAttributes, LlmRequest, LlmRequestInterceptOutcome,
50};
51
52const OBSERVABILITY_CREDENTIAL_HEADERS: [&str; 7] = [
53    "authorization",
54    "proxy-authorization",
55    "cookie",
56    "x-api-key",
57    "api-key",
58    "anthropic-api-key",
59    "x-goog-api-key",
60];
61
62fn queue_sanitized_event_with_scope_stack(
63    event: Event,
64    subscribers: &[EventSubscriberFn],
65    scope_stack: &ScopeStackHandle,
66) -> bool {
67    let sanitizers = snapshot_event_sanitizers(&event, scope_stack).unwrap_or_default();
68    dispatch_sanitized_event(event, sanitizers, subscribers, scope_stack.clone())
69}
70
71#[derive(Clone)]
72struct CapturedLlmScopeStack(ScopeStackHandle);
73
74impl Default for CapturedLlmScopeStack {
75    fn default() -> Self {
76        Self(current_scope_stack())
77    }
78}
79
80impl std::fmt::Debug for CapturedLlmScopeStack {
81    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        formatter.write_str("CapturedLlmScopeStack(..)")
83    }
84}
85
86/// Runtime-owned handle identifying an active or completed LLM call.
87#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
88#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
89pub struct LlmHandle {
90    /// Unique LLM-call identifier.
91    #[builder(default = Uuid::now_v7())]
92    pub uuid: Uuid,
93    /// Timestamp captured when the LLM handle was created.
94    #[builder(default = Utc::now())]
95    pub started_at: DateTime<Utc>,
96    /// Provider or logical call name recorded on lifecycle events.
97    ///
98    /// Gateway-managed provider calls use provider route names such as
99    /// `anthropic.messages`; event normalization may reuse those route names as
100    /// codec hints when raw request shapes overlap across providers.
101    #[builder(setter(into))]
102    pub name: String,
103    /// Optional application payload stored on the handle.
104    #[builder(default)]
105    pub data: Option<Json>,
106    /// Optional metadata attached to the LLM span.
107    #[builder(default)]
108    pub metadata: Option<Json>,
109    /// LLM behavior flags.
110    #[builder(default = LlmAttributes::empty())]
111    pub attributes: LlmAttributes,
112    /// UUID of the parent scope, if any.
113    #[builder(default)]
114    pub parent_uuid: Option<Uuid>,
115    /// Optional normalized model name for observability.
116    #[builder(default, setter(into))]
117    pub model_name: Option<String>,
118    /// Bounded, in-memory optimization evidence recorder for this call.
119    #[serde(skip, default)]
120    #[builder(default)]
121    pub optimization_recorder: LlmOptimizationRecorder,
122    /// Scope stack captured when the LLM lifecycle starts.
123    ///
124    /// Close-time work can run from a different task, especially for streams,
125    /// so optimization marks must not consult the poller's ambient scope.
126    #[serde(skip, default)]
127    #[builder(setter(skip), default)]
128    captured_scope_stack: CapturedLlmScopeStack,
129}
130
131impl LlmHandle {
132    pub(crate) fn captured_scope_stack(&self) -> &ScopeStackHandle {
133        &self.captured_scope_stack.0
134    }
135}
136
137/// Builder parameters for [`NemoRelayContextState::create_llm_handle`].
138#[derive(Debug, Clone, TypedBuilder)]
139#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
140pub struct CreateLlmHandleParams<'a> {
141    /// Logical provider or model family name. Gateway-managed provider calls
142    /// should pass the provider route name, for example `anthropic.messages`.
143    pub name: &'a str,
144    /// Optional parent scope UUID.
145    #[builder(default)]
146    pub parent_uuid: Option<uuid::Uuid>,
147    /// LLM attribute bitflags.
148    #[builder(default = LlmAttributes::empty())]
149    pub attributes: LlmAttributes,
150    /// Optional application payload stored on the handle.
151    #[builder(default)]
152    pub data: Option<Json>,
153    /// Optional metadata stored on the handle.
154    #[builder(default)]
155    pub metadata: Option<Json>,
156    /// Optional normalized model name stored on the handle.
157    #[builder(default, setter(into))]
158    pub model_name: Option<String>,
159    /// Optional timestamp captured as the handle start time and reused by the
160    /// emitted start event. When omitted, the current UTC time is used.
161    #[builder(default)]
162    pub timestamp: Option<DateTime<Utc>>,
163}
164
165/// Builder parameters for [`NemoRelayContextState::build_llm_end_event`].
166#[derive(Clone, TypedBuilder)]
167#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
168pub struct EndLlmHandleParams<'a> {
169    /// LLM handle to serialize into the emitted end event.
170    pub handle: &'a LlmHandle,
171    /// Optional data payload merged over the handle data.
172    #[builder(default)]
173    pub data: Option<Json>,
174    /// Optional metadata payload merged over the handle metadata.
175    #[builder(default)]
176    pub metadata: Option<Json>,
177    /// Optional normalized response annotation produced by a response codec.
178    #[builder(default)]
179    pub annotated_response: Option<Arc<AnnotatedLlmResponse>>,
180    /// Optional timestamp recorded on the emitted end event. When omitted, the
181    /// runtime records the current UTC time, or one microsecond after the
182    /// handle start time if the current time is not later.
183    #[builder(default)]
184    pub timestamp: Option<DateTime<Utc>>,
185}
186
187/// Builder parameters for [`llm_call`].
188#[derive(TypedBuilder)]
189#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
190pub struct LlmCallParams<'a> {
191    /// Logical provider or model family name recorded on the span.
192    pub name: &'a str,
193    /// Raw request associated with the span.
194    pub request: &'a LlmRequest,
195    /// Optional explicit parent scope.
196    #[builder(default)]
197    pub parent: Option<&'a ScopeHandle>,
198    /// LLM attribute bitflags applied to the span.
199    #[builder(default = LlmAttributes::empty())]
200    pub attributes: LlmAttributes,
201    /// Optional application payload stored on the handle but not emitted as
202    /// Agent Trajectory Observability Format (ATOF) data.
203    #[builder(default)]
204    pub data: Option<Json>,
205    /// Optional JSON metadata recorded on the start event.
206    #[builder(default)]
207    pub metadata: Option<Json>,
208    /// Optional normalized model name recorded separately from the request payload.
209    #[builder(default, setter(into))]
210    pub model_name: Option<String>,
211    /// Optional normalized request annotation produced by a codec.
212    #[builder(default)]
213    pub annotated_request: Option<Arc<AnnotatedLlmRequest>>,
214    /// Optional timestamp captured as the handle start time and reused by the
215    /// emitted start event. When omitted, the current UTC time is used.
216    #[builder(default)]
217    pub timestamp: Option<DateTime<Utc>>,
218}
219
220/// Builder parameters for [`llm_call_execute`].
221#[derive(TypedBuilder)]
222#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
223pub struct LlmCallExecuteParams {
224    /// Logical provider or model family name recorded on emitted events.
225    #[builder(setter(into))]
226    pub name: String,
227    /// Raw request passed into the managed pipeline.
228    pub request: LlmRequest,
229    /// Provider callback or execution continuation.
230    pub func: LlmExecutionNextFn,
231    /// Optional explicit parent scope for the emitted LLM span.
232    #[builder(default)]
233    pub parent: Option<ScopeHandle>,
234    /// LLM attribute bitflags applied to the managed span.
235    #[builder(default = LlmAttributes::empty())]
236    pub attributes: LlmAttributes,
237    /// Optional application payload stored on the handle but not emitted as
238    /// Agent Trajectory Observability Format (ATOF) data.
239    #[builder(default)]
240    pub data: Option<Json>,
241    /// Optional JSON metadata recorded on emitted events.
242    #[builder(default)]
243    pub metadata: Option<Json>,
244    /// Optional normalized model name for observability output.
245    #[builder(default, setter(into))]
246    pub model_name: Option<String>,
247    /// Optional request codec used to produce annotated request data.
248    #[builder(default)]
249    pub codec: Option<Arc<dyn LlmCodec>>,
250    /// Optional response codec used to attach annotated response data.
251    #[builder(default)]
252    pub response_codec: Option<Arc<dyn LlmResponseCodec>>,
253}
254
255/// Builder parameters for [`llm_stream_call_execute`].
256#[derive(TypedBuilder)]
257#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
258pub struct LlmStreamCallExecuteParams {
259    /// Logical provider or model family name recorded on emitted events.
260    #[builder(setter(into))]
261    pub name: String,
262    /// Raw request passed into the managed pipeline.
263    pub request: LlmRequest,
264    /// Streaming provider callback or execution continuation.
265    pub func: LlmStreamExecutionNextFn,
266    /// Per-chunk collector callback used to accumulate stream state.
267    pub collector: LlmCollectorFn,
268    /// Finalizer callback used to construct the completed response.
269    pub finalizer: LlmFinalizerFn,
270    /// Optional explicit parent scope for the emitted LLM span.
271    #[builder(default)]
272    pub parent: Option<ScopeHandle>,
273    /// LLM attribute bitflags applied to the managed span.
274    #[builder(default = LlmAttributes::empty())]
275    pub attributes: LlmAttributes,
276    /// Optional application payload stored on the handle but not emitted as
277    /// Agent Trajectory Observability Format (ATOF) data.
278    #[builder(default)]
279    pub data: Option<Json>,
280    /// Optional JSON metadata recorded on emitted events.
281    #[builder(default)]
282    pub metadata: Option<Json>,
283    /// Optional normalized model name for observability output.
284    #[builder(default, setter(into))]
285    pub model_name: Option<String>,
286    /// Optional request codec used to produce annotated request data.
287    #[builder(default)]
288    pub codec: Option<Arc<dyn LlmCodec>>,
289    /// Optional response codec used to attach annotated response data.
290    #[builder(default)]
291    pub response_codec: Option<Arc<dyn LlmResponseCodec>>,
292}
293
294/// Builder parameters for [`llm_call_end`].
295#[derive(TypedBuilder)]
296#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
297pub struct LlmCallEndParams<'a> {
298    /// LLM handle to close.
299    pub handle: &'a LlmHandle,
300    /// Raw provider response associated with the end event.
301    pub response: Json,
302    /// Optional application payload retained for compatibility; Agent
303    /// Trajectory Observability Format (ATOF) data is the response.
304    #[builder(default)]
305    pub data: Option<Json>,
306    /// Optional JSON metadata recorded on the end event.
307    #[builder(default)]
308    pub metadata: Option<Json>,
309    /// Optional normalized response annotation produced by a response codec.
310    #[builder(default)]
311    pub annotated_response: Option<Arc<AnnotatedLlmResponse>>,
312    /// Optional response codec used to produce an annotation from sanitized event data.
313    #[builder(default)]
314    pub response_codec: Option<Arc<dyn LlmResponseCodec>>,
315    /// Optional timestamp recorded on the emitted end event. When omitted, the
316    /// runtime records the current UTC time, or one microsecond after the
317    /// handle start time if the current time is not later.
318    #[builder(default)]
319    pub timestamp: Option<DateTime<Utc>>,
320}
321
322fn create_llm_handle(params: CreateLlmHandleParams<'_>) -> Result<LlmHandle> {
323    ensure_runtime_owner()?;
324    let context = global_context();
325    let state = context
326        .read()
327        .map_err(|error| FlowError::Internal(error.to_string()))?;
328    Ok(state.create_llm_handle(params))
329}
330
331fn request_turn_projection_needed<T>(
332    items: &[T],
333    is_user: &impl Fn(&T) -> bool,
334    is_instruction: &impl Fn(&T) -> bool,
335) -> bool {
336    let Some(last_index) = items.len().checked_sub(1) else {
337        return false;
338    };
339    match items.iter().rposition(is_user) {
340        Some(start) => items[..start].iter().any(|item| !is_instruction(item)),
341        None => items
342            .iter()
343            .enumerate()
344            .any(|(index, item)| index != last_index && !is_instruction(item)),
345    }
346}
347
348fn retain_current_request_turn<T>(
349    items: &mut Vec<T>,
350    is_user: impl Fn(&T) -> bool,
351    is_instruction: impl Fn(&T) -> bool,
352) -> bool {
353    if !request_turn_projection_needed(items, &is_user, &is_instruction) {
354        return false;
355    }
356    let last_index = items.len() - 1;
357    let Some(start) = items.iter().rposition(is_user) else {
358        let mut index = 0;
359        items.retain(|item| {
360            let retain = index == last_index || is_instruction(item);
361            index += 1;
362            retain
363        });
364        return true;
365    };
366    let mut current_turn = items.split_off(start);
367    items.retain(is_instruction);
368    items.append(&mut current_turn);
369    true
370}
371
372fn project_llm_request_to_current_user_turn(
373    request: &mut LlmRequest,
374    annotated_request: &mut Option<Arc<AnnotatedLlmRequest>>,
375    request_codec: Option<&dyn LlmCodec>,
376) {
377    let Some(annotation) = annotated_request.as_mut() else {
378        return;
379    };
380    if !request_turn_projection_needed(
381        &annotation.messages,
382        &|message| matches!(message, Message::User { .. }),
383        &|message| matches!(message, Message::System { .. }),
384    ) {
385        return;
386    }
387    let original_annotation = request_codec.map(|_| Arc::clone(annotation));
388    let projected = limit_annotated_request_history_to_current_user_turn(Arc::make_mut(annotation));
389    debug_assert!(projected);
390    if let Some(codec) = request_codec {
391        match codec.encode(annotation, request) {
392            Ok(encoded) => *request = encoded,
393            Err(_) => {
394                log::warn!(
395                    target: "nemo_relay.observability",
396                    event = "projection_failed",
397                    projection = "llm_current_turn",
398                    recovery = "preserve_full_history";
399                    "LLM request projection failed; preserving full event history"
400                );
401                *annotation = original_annotation
402                    .expect("codec-backed projection should preserve the original annotation")
403            }
404        }
405    }
406}
407
408fn limit_annotated_request_history_to_current_user_turn(
409    annotated_request: &mut AnnotatedLlmRequest,
410) -> bool {
411    retain_current_request_turn(
412        &mut annotated_request.messages,
413        |message| matches!(message, Message::User { .. }),
414        |message| matches!(message, Message::System { .. }),
415    )
416}
417
418async fn emit_llm_start_with_subscribers(
419    handle: &LlmHandle,
420    request: &LlmRequest,
421    annotated_request: Option<Arc<AnnotatedLlmRequest>>,
422    request_codec: Option<Arc<dyn LlmCodec>>,
423    subscribers: &[EventSubscriberFn],
424) -> Result<()> {
425    ensure_runtime_owner()?;
426    let entries = {
427        let scope_stack = handle.captured_scope_stack();
428        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
429        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
430            &registries.llm_sanitize_request_guardrails
431        });
432        let context = global_context();
433        let state = context
434            .read()
435            .map_err(|error| FlowError::Internal(error.to_string()))?;
436        state.llm_sanitize_request_entries(&scope_locals)
437    };
438    let observable_request = remove_observability_credential_headers(request.clone());
439    let mut sanitized_request = NemoRelayContextState::llm_sanitize_request_snapshot_chain(
440        observable_request.clone(),
441        LlmSanitizeRequestContext::for_request_codec(request_codec.clone()),
442        &entries,
443    )
444    .await;
445    let request_changed = sanitized_request
446        .as_ref()
447        .is_some_and(|sanitized_request| sanitized_request != &observable_request);
448    let mut annotated_request = match (sanitized_request.as_ref(), request_codec.as_deref()) {
449        (Some(sanitized_request), Some(codec)) if request_changed => {
450            codec.decode(sanitized_request).ok().map(Arc::new)
451        }
452        (Some(_), _) if !request_changed => annotated_request,
453        (None, _) => None,
454        (Some(_), _) => None,
455    };
456    let scope_stack = handle.captured_scope_stack();
457    let agent_is_fresh = {
458        let mut scope_guard = scope_stack.write().expect("scope stack lock poisoned");
459        scope_guard.take_agent_freshness(handle.parent_uuid)
460    };
461    if !agent_is_fresh && let Some(sanitized_request) = sanitized_request.as_mut() {
462        project_llm_request_to_current_user_turn(
463            sanitized_request,
464            &mut annotated_request,
465            request_codec.as_deref(),
466        );
467    }
468    let input = sanitized_request
469        .as_ref()
470        .and_then(|sanitized_request| serde_json::to_value(sanitized_request).ok());
471    let event = {
472        let context = global_context();
473        let state = context
474            .read()
475            .map_err(|error| FlowError::Internal(error.to_string()))?;
476        state.build_llm_start_event(handle, input, annotated_request)
477    };
478    queue_sanitized_event_with_scope_stack(event, subscribers, scope_stack);
479    Ok(())
480}
481
482fn remove_observability_credential_headers(mut request: LlmRequest) -> LlmRequest {
483    request.headers.retain(|name, _| {
484        !OBSERVABILITY_CREDENTIAL_HEADERS
485            .iter()
486            .any(|credential_header| name.eq_ignore_ascii_case(credential_header))
487    });
488    request
489}
490
491/// Synchronous test seam retained for lifecycle unit tests. Public manual
492/// lifecycle emission is synchronous too, but its work is queued; this helper
493/// exercises the managed start-event transformation directly.
494#[cfg(test)]
495fn emit_llm_start(
496    handle: &LlmHandle,
497    request: &LlmRequest,
498    annotated_request: Option<Arc<AnnotatedLlmRequest>>,
499    request_codec: Option<Arc<dyn LlmCodec>>,
500) -> Result<()> {
501    let subscribers = {
502        let scope_stack = handle.captured_scope_stack();
503        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
504        snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?
505    };
506    crate::api::runtime::subscriber_dispatcher::block_on_sanitizer_future(
507        emit_llm_start_with_subscribers(
508            handle,
509            request,
510            annotated_request,
511            request_codec,
512            &subscribers,
513        ),
514    )
515    .map_err(FlowError::Internal)?
516}
517
518async fn emit_pending_request_marks(
519    handle: &LlmHandle,
520    marks: Vec<PendingMarkSpec>,
521    subscribers: &[EventSubscriberFn],
522) -> Result<()> {
523    if marks.is_empty() {
524        return Ok(());
525    }
526    ensure_runtime_owner()?;
527    let timestamp = handle.started_at + TimeDelta::microseconds(1);
528    for mark in marks {
529        let event = Event::Mark(MarkEvent::new(
530            BaseEvent::builder()
531                .name(mark.name)
532                .parent_uuid(handle.uuid)
533                .timestamp(timestamp)
534                .data_opt(mark.data)
535                .metadata_opt(mark.metadata)
536                .build(),
537            mark.category,
538            mark.category_profile,
539        ));
540        queue_sanitized_event_with_scope_stack(event, subscribers, handle.captured_scope_stack());
541    }
542    Ok(())
543}
544
545pub(crate) async fn emit_optimization_marks(handle: &LlmHandle, subscribers: &[EventSubscriberFn]) {
546    emit_optimization_marks_with_async(
547        handle,
548        subscribers,
549        |event| async { Some(event) },
550        |event, subscribers| {
551            queue_sanitized_event_with_scope_stack(
552                event.clone(),
553                subscribers,
554                handle.captured_scope_stack(),
555            )
556        },
557    )
558    .await;
559}
560
561pub(crate) async fn emit_reserved_optimization_marks(
562    handle: &LlmHandle,
563    subscribers: &[EventSubscriberFn],
564) {
565    emit_optimization_marks_with_async(
566        handle,
567        subscribers,
568        |event| async { Some(event) },
569        |event, subscribers| {
570            let sanitizers =
571                snapshot_event_sanitizers(event, handle.captured_scope_stack()).unwrap_or_default();
572            dispatch_reserved_sanitized_event(
573                event.clone(),
574                sanitizers,
575                subscribers,
576                handle.captured_scope_stack().clone(),
577            )
578        },
579    )
580    .await;
581}
582
583/// Queue optimization marks from a synchronous lifecycle API.
584///
585/// The public manual lifecycle APIs must not await middleware. Capture each
586/// event's sanitizer chain now and enqueue the immutable snapshots ahead of
587/// the corresponding end event, preserving publication order.
588fn enqueue_optimization_marks(handle: &LlmHandle, subscribers: &[EventSubscriberFn]) {
589    let contributions = handle.optimization_recorder.unemitted_with_timestamps();
590    if contributions.is_empty() || ensure_runtime_owner().is_err() {
591        return;
592    }
593    let scope_stack = handle.captured_scope_stack().clone();
594    for (contribution, recorded_at) in contributions {
595        let event = optimization_mark_event(handle, &contribution, recorded_at);
596        let sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
597        if dispatch_sanitized_event(event, sanitizers, subscribers, scope_stack.clone()) {
598            handle.optimization_recorder.mark_emitted(1);
599        } else {
600            break;
601        }
602    }
603}
604
605async fn emit_optimization_marks_with_async<F, Fut>(
606    handle: &LlmHandle,
607    subscribers: &[EventSubscriberFn],
608    mut sanitize: F,
609    mut enqueue: impl FnMut(&Event, &[EventSubscriberFn]) -> bool,
610) where
611    F: FnMut(Event) -> Fut,
612    Fut: Future<Output = Option<Event>>,
613{
614    let contributions = handle.optimization_recorder.unemitted_with_timestamps();
615    if contributions.is_empty() {
616        return;
617    }
618    if ensure_runtime_owner().is_err() {
619        log::warn!(
620            target: "nemo_relay.observability",
621            event = "optimization_marks_skipped",
622            reason = "runtime_owner_unavailable",
623            contribution_count = contributions.len();
624            "LLM optimization marks were skipped"
625        );
626        return;
627    }
628    for (contribution, recorded_at) in contributions {
629        let event = optimization_mark_event(handle, &contribution, recorded_at);
630        let Some(event) = sanitize(event).await else {
631            // Sanitizers currently rewrite fields rather than intentionally
632            // dropping events. `None` means the sanitizer context was
633            // unavailable, so preserve this ordered suffix for a later retry.
634            break;
635        };
636        if enqueue(&event, subscribers) {
637            handle.optimization_recorder.mark_emitted(1);
638        } else {
639            // Preserve this item and the remaining ordered suffix for a later
640            // lifecycle boundary. Accounting remains best effort and must not
641            // alter the provider result.
642            break;
643        }
644    }
645}
646
647fn optimization_mark_event(
648    handle: &LlmHandle,
649    contribution: &crate::codec::optimization::LlmOptimizationContribution,
650    recorded_at: DateTime<Utc>,
651) -> Event {
652    let offset = contribution.sequence.unwrap_or(0).saturating_add(2);
653    let offset = i64::try_from(offset).unwrap_or(i64::MAX);
654    let request_ordered_timestamp = handle.started_at + TimeDelta::microseconds(offset);
655    Event::Mark(MarkEvent::new(
656        BaseEvent::builder()
657            .name("nemo_relay.llm.optimization")
658            .parent_uuid(handle.uuid)
659            .timestamp(recorded_at.max(request_ordered_timestamp))
660            .data(serde_json::to_value(contribution).unwrap_or(Json::Null))
661            .data_schema(DataSchema {
662                name: "nemo.relay.llm_optimization_contribution".to_string(),
663                version: "1".to_string(),
664            })
665            .build(),
666        Some(EventCategory::custom()),
667        Some(
668            CategoryProfile::builder()
669                .subtype("nemo_relay.llm.optimization")
670                .build(),
671        ),
672    ))
673}
674
675/// Synchronous test seam for optimization-mark accounting. Production paths
676/// always use [`emit_optimization_marks_with_async`]; unit tests use this seam
677/// to isolate cursor behavior from asynchronous event publication.
678#[cfg(test)]
679fn emit_optimization_marks_with<F>(
680    handle: &LlmHandle,
681    subscribers: &[EventSubscriberFn],
682    mut sanitize: F,
683    mut enqueue: impl FnMut(&Event, &[EventSubscriberFn]) -> bool,
684) where
685    F: FnMut(Event) -> Option<Event>,
686{
687    let contributions = handle.optimization_recorder.unemitted_with_timestamps();
688    if contributions.is_empty() || ensure_runtime_owner().is_err() {
689        return;
690    }
691    for (contribution, recorded_at) in contributions {
692        let event = optimization_mark_event(handle, &contribution, recorded_at);
693        let Some(event) = sanitize(event) else {
694            break;
695        };
696        if enqueue(&event, subscribers) {
697            handle.optimization_recorder.mark_emitted(1);
698        } else {
699            break;
700        }
701    }
702}
703
704/// Start a manual LLM lifecycle span.
705///
706/// This emits an LLM-start event after applying sanitize-request guardrails to
707/// the payload recorded for observability.
708///
709/// If a sanitizer errors or panics, Relay omits the payload and request
710/// annotation and does not run remaining sanitizers.
711///
712/// # Parameters
713/// - `name`: Logical provider or model family name recorded on the span.
714/// - `request`: Raw [`LlmRequest`] associated with the span.
715/// - `parent`: Optional explicit parent scope.
716/// - `attributes`: LLM attribute bitflags applied to the span.
717/// - `data`: Optional application payload stored on the returned handle. The
718///   emitted start event data is the sanitized `request` payload.
719/// - `metadata`: Optional JSON metadata recorded on the start event.
720/// - `model_name`: Optional normalized model name recorded separately from the
721///   request payload.
722/// - `annotated_request`: Optional normalized request annotation produced by a
723///   codec.
724/// - `timestamp`: Optional timestamp recorded as the handle start time and on
725///   the emitted start event. When `None`, the current UTC time is used.
726///
727/// # Returns
728/// A [`Result`] containing the created [`LlmHandle`] after its start-event
729/// snapshot has been submitted for queued publication.
730///
731/// # Errors
732/// Returns an error when the runtime owner check fails or when internal state
733/// cannot be read safely. Dispatcher submission failures are logged because
734/// observability publication is best effort.
735///
736/// # Notes
737/// The runtime removes standard credential headers (`authorization`,
738/// `proxy-authorization`, `cookie`, `x-api-key`, `api-key`,
739/// `anthropic-api-key`, and `x-goog-api-key`) from the event-only request copy
740/// before sanitize-request guardrails run. This does not change the
741/// caller-owned [`LlmRequest`]. When the owning agent is not fresh, the emitted
742/// request annotation is limited to the current user turn. Managed calls with a
743/// request codec also apply that projection to the event input, without changing
744/// the request used for provider execution.
745pub fn llm_call(params: LlmCallParams<'_>) -> Result<LlmHandle> {
746    let handle_params = CreateLlmHandleParams::builder()
747        .name(params.name)
748        .parent_uuid_opt(resolve_parent_uuid(params.parent))
749        .attributes(params.attributes)
750        .data_opt(params.data)
751        .metadata_opt(params.metadata)
752        .model_name_opt(params.model_name)
753        .timestamp_opt(params.timestamp)
754        .build();
755    let handle = create_llm_handle(handle_params)?;
756    let scope_stack = handle.captured_scope_stack().clone();
757    let (entries, subscribers, agent_is_fresh) = {
758        let mut scope_guard = scope_stack
759            .write()
760            .map_err(|error| FlowError::Internal(error.to_string()))?;
761        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
762            &registries.llm_sanitize_request_guardrails
763        });
764        let subscribers =
765            snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?;
766        let context = global_context();
767        let state = context
768            .read()
769            .map_err(|error| FlowError::Internal(error.to_string()))?;
770        let entries = state.llm_sanitize_request_entries(&scope_locals);
771        drop(state);
772        let agent_is_fresh = scope_guard.take_agent_freshness(handle.parent_uuid);
773        (entries, subscribers, agent_is_fresh)
774    };
775    // Middleware and event publication only observe a credential-free copy.
776    // Keep `params.request` untouched: it remains the caller/provider request.
777    let request = remove_observability_credential_headers(params.request.clone());
778    let annotated_request = params.annotated_request;
779    let event = {
780        let context = global_context();
781        let state = context
782            .read()
783            .map_err(|error| FlowError::Internal(error.to_string()))?;
784        state.build_llm_start_event(&handle, None, None)
785    };
786    let queued_handle = handle.clone();
787    let event_sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
788    dispatch_transformed_event(
789        event,
790        Box::new(move |event| {
791            Box::pin(async move {
792                let mut sanitized_request =
793                    NemoRelayContextState::llm_sanitize_request_snapshot_chain(
794                        request.clone(),
795                        LlmSanitizeRequestContext::default(),
796                        &entries,
797                    )
798                    .await;
799                let request_changed = sanitized_request
800                    .as_ref()
801                    .is_some_and(|sanitized| sanitized != &request);
802                let mut annotation = if sanitized_request.is_none() || request_changed {
803                    None
804                } else {
805                    annotated_request
806                };
807                if !agent_is_fresh && let Some(sanitized_request) = sanitized_request.as_mut() {
808                    project_llm_request_to_current_user_turn(
809                        sanitized_request,
810                        &mut annotation,
811                        None,
812                    );
813                }
814                let input = sanitized_request
815                    .as_ref()
816                    .and_then(|request| serde_json::to_value(request).ok());
817                let context = global_context();
818                match context.read() {
819                    Ok(state) => state.build_llm_start_event(&queued_handle, input, annotation),
820                    Err(_) => event,
821                }
822            })
823        }),
824        event_sanitizers,
825        &subscribers,
826        scope_stack,
827    );
828    Ok(handle)
829}
830
831#[derive(Clone, Copy)]
832struct LlmCallEndBehavior {
833    response_codec_errors_fatal: bool,
834    attach_estimated_cost: bool,
835}
836
837struct LlmEndPayload {
838    data: Option<Json>,
839    annotated_response: Option<Arc<AnnotatedLlmResponse>>,
840    decode_error: Option<FlowError>,
841}
842
843async fn build_llm_end_payload(
844    handle: &LlmHandle,
845    response: Json,
846    fallback_data: Option<Json>,
847    annotated_response: Option<Arc<AnnotatedLlmResponse>>,
848    response_codec: Option<Arc<dyn LlmResponseCodec>>,
849    entries: &[crate::api::registry::Guardrail<crate::api::runtime::LlmSanitizeResponseFn>],
850    behavior: LlmCallEndBehavior,
851) -> LlmEndPayload {
852    let response_was_null_without_fallback = response.is_null() && fallback_data.is_none();
853    let response = if response.is_null() {
854        fallback_data.unwrap_or(response)
855    } else {
856        response
857    };
858    let sanitized_response = NemoRelayContextState::llm_sanitize_response_snapshot_chain(
859        response.clone(),
860        LlmSanitizeResponseContext::for_response_codec(response_codec.clone()),
861        entries,
862    )
863    .await;
864    let response_changed = sanitized_response
865        .as_ref()
866        .is_some_and(|sanitized_response| sanitized_response != &response);
867    let data = match sanitized_response {
868        Some(response) if response_was_null_without_fallback && response.is_null() => None,
869        response => response,
870    };
871    let annotation_omitted = data.as_ref().is_none_or(Json::is_null);
872    let (mut annotated_response, decode_error) = if annotation_omitted {
873        (None, None)
874    } else {
875        resolve_llm_end_annotation(
876            (!response_changed).then_some(annotated_response).flatten(),
877            response_codec,
878            data.as_ref(),
879            &behavior,
880            &handle.name,
881        )
882    };
883    let pricing = crate::codec::response::active_pricing_resolver();
884    let summary = finalize_optimization_summary(
885        &handle.optimization_recorder,
886        annotated_response.as_mut(),
887        handle.model_name.as_deref(),
888        &pricing,
889    );
890    if !annotation_omitted
891        && annotated_response.is_none()
892        && let Some(summary) = summary
893    {
894        annotated_response = Some(AnnotatedLlmResponse {
895            optimization_summary: Some(summary),
896            ..AnnotatedLlmResponse::default()
897        });
898    }
899    LlmEndPayload {
900        data,
901        annotated_response: annotated_response.map(Arc::new),
902        decode_error,
903    }
904}
905
906/// Finish a manual LLM lifecycle span.
907///
908/// This emits an LLM-end event for a handle previously returned by
909/// [`llm_call`].
910///
911/// # Parameters
912/// - `handle`: LLM handle to close.
913/// - `response`: Raw provider response associated with the end event.
914/// - `data`: Optional application payload retained for compatibility. When the
915///   raw `response` is JSON null, this payload is sanitized in its place.
916/// - `metadata`: Optional JSON metadata recorded on the end event.
917/// - `annotated_response`: Optional normalized response annotation produced by
918///   a response codec. When omitted and `response_codec` is supplied, the
919///   annotation is decoded from the sanitized end-event payload.
920/// - `response_codec`: Optional response codec used to produce a normalized
921///   response annotation from the sanitized end-event payload.
922/// - `timestamp`: Optional timestamp recorded on the emitted end event. When
923///   `None`, the runtime uses the current UTC time, or one microsecond after
924///   the handle start time if the current time is not later.
925///
926/// # Returns
927/// A [`Result`] that is `Ok(())` when the end event has been queued for
928/// sanitization and publication.
929///
930/// # Errors
931/// Returns an error when the runtime owner check fails or internal state cannot
932/// be read safely. Dispatcher submission failures are logged because
933/// observability publication is best effort. Sanitizer errors discovered during
934/// queued publication are logged and fail closed by omitting the governed payload.
935/// Response-codec errors retain their documented fallback behavior.
936///
937/// # Notes
938/// Sanitize-response guardrails affect only the emitted end-event payload, not
939/// the caller-owned `response` value. If a sanitizer errors or panics, Relay
940/// omits the payload and response annotation and does not run remaining sanitizers.
941pub fn llm_call_end(params: LlmCallEndParams<'_>) -> Result<()> {
942    ensure_runtime_owner()?;
943    let scope_stack = params.handle.captured_scope_stack().clone();
944    let (entries, subscribers) = {
945        let scope_guard = scope_stack
946            .read()
947            .map_err(|error| FlowError::Internal(error.to_string()))?;
948        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
949            &registries.llm_sanitize_response_guardrails
950        });
951        let subscribers =
952            snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?;
953        let context = global_context();
954        let state = context
955            .read()
956            .map_err(|error| FlowError::Internal(error.to_string()))?;
957        (
958            state.llm_sanitize_response_entries(&scope_locals),
959            subscribers,
960        )
961    };
962    let response = params.response;
963    let fallback_data = params.data;
964    let handle = params.handle.clone();
965    let metadata = params.metadata;
966    let timestamp = params.timestamp;
967    let annotated_response = params.annotated_response;
968    let response_codec = params.response_codec;
969    handle.optimization_recorder.close_for_finalization(None);
970    enqueue_optimization_marks(&handle, &subscribers);
971    let event = {
972        let context = global_context();
973        let state = context
974            .read()
975            .map_err(|error| FlowError::Internal(error.to_string()))?;
976        state.build_llm_end_event(
977            EndLlmHandleParams::builder()
978                .handle(&handle)
979                .data(Json::Null)
980                .metadata_opt(metadata.clone())
981                .annotated_response_opt(annotated_response.clone())
982                .timestamp_opt(timestamp)
983                .build(),
984        )
985    };
986    let event_sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
987    dispatch_transformed_event(
988        event,
989        Box::new(move |event| {
990            Box::pin(async move {
991                let payload = build_llm_end_payload(
992                    &handle,
993                    response,
994                    fallback_data,
995                    annotated_response,
996                    response_codec,
997                    &entries,
998                    LlmCallEndBehavior {
999                        response_codec_errors_fatal: false,
1000                        attach_estimated_cost: false,
1001                    },
1002                )
1003                .await;
1004                if let Some(error) = payload.decode_error {
1005                    log::error!(
1006                        target: "nemo_relay.runtime",
1007                        event = "manual_llm_response_codec_failed";
1008                        "Manual LLM response annotation failed during queued publication: {error}"
1009                    );
1010                }
1011                let context = global_context();
1012                let Ok(state) = context.read() else {
1013                    return event;
1014                };
1015                let end_metadata = metadata_with_otel_status(metadata, "OK", None);
1016                state.build_llm_end_event(
1017                    EndLlmHandleParams::builder()
1018                        .handle(&handle)
1019                        .data_opt(payload.data)
1020                        .metadata_opt(end_metadata)
1021                        .annotated_response_opt(payload.annotated_response)
1022                        .timestamp_opt(timestamp)
1023                        .build(),
1024                )
1025            })
1026        }),
1027        event_sanitizers,
1028        &subscribers,
1029        scope_stack,
1030    );
1031    Ok(())
1032}
1033
1034async fn llm_call_end_with_behavior(
1035    params: LlmCallEndParams<'_>,
1036    behavior: LlmCallEndBehavior,
1037    lifecycle_subscribers: Option<&[EventSubscriberFn]>,
1038) -> Result<()> {
1039    let LlmCallEndParams {
1040        handle,
1041        response,
1042        data,
1043        metadata,
1044        annotated_response,
1045        response_codec,
1046        timestamp,
1047    } = params;
1048    ensure_runtime_owner()?;
1049    let (entries, subscribers) = {
1050        let scope_stack = handle.captured_scope_stack();
1051        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1052        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1053            &registries.llm_sanitize_response_guardrails
1054        });
1055        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
1056        let subscribers = match lifecycle_subscribers {
1057            Some(subscribers) => subscribers.to_vec(),
1058            None => snapshot_event_subscribers(scope_subscribers)?,
1059        };
1060        let context = global_context();
1061        let state = context
1062            .read()
1063            .map_err(|error| FlowError::Internal(error.to_string()))?;
1064        let entries = state.llm_sanitize_response_entries(&scope_locals);
1065        (entries, subscribers)
1066    };
1067    handle.optimization_recorder.close_for_finalization(None);
1068    emit_optimization_marks(handle, &subscribers).await;
1069    let payload = build_llm_end_payload(
1070        handle,
1071        response,
1072        data,
1073        annotated_response,
1074        response_codec,
1075        &entries,
1076        behavior,
1077    )
1078    .await;
1079    let event = {
1080        let context = global_context();
1081        let state = context
1082            .read()
1083            .map_err(|error| FlowError::Internal(error.to_string()))?;
1084        let end_metadata = metadata_with_otel_status(metadata, "OK", None);
1085        state.build_llm_end_event(
1086            EndLlmHandleParams::builder()
1087                .handle(handle)
1088                .data_opt(payload.data)
1089                .metadata_opt(end_metadata)
1090                .annotated_response_opt(payload.annotated_response)
1091                .timestamp_opt(timestamp)
1092                .build(),
1093        )
1094    };
1095    queue_sanitized_event_with_scope_stack(event, &subscribers, handle.captured_scope_stack());
1096    if let Some(error) = payload.decode_error
1097        && behavior.response_codec_errors_fatal
1098    {
1099        Err(error)
1100    } else {
1101        Ok(())
1102    }
1103}
1104
1105#[cfg(test)]
1106fn sanitize_context_for_request_codec(codec: Option<&dyn LlmCodec>) -> LlmSanitizeRequestContext {
1107    LlmSanitizeRequestContext::with_identity(
1108        codec.map_or(LlmCodecIdentity::None, LlmCodec::codec_identity),
1109    )
1110}
1111
1112#[cfg(test)]
1113pub(crate) fn sanitize_context_for_response_codec(
1114    codec: Option<&dyn LlmResponseCodec>,
1115) -> LlmSanitizeResponseContext {
1116    LlmSanitizeResponseContext::with_identity(
1117        codec.map_or(LlmCodecIdentity::None, LlmResponseCodec::codec_identity),
1118    )
1119}
1120
1121fn resolve_llm_end_annotation(
1122    annotated_response: Option<Arc<AnnotatedLlmResponse>>,
1123    response_codec: Option<Arc<dyn LlmResponseCodec>>,
1124    data: Option<&Json>,
1125    behavior: &LlmCallEndBehavior,
1126    provider_name: &str,
1127) -> (Option<AnnotatedLlmResponse>, Option<FlowError>) {
1128    if let Some(annotated_response) = annotated_response {
1129        return (Some((*annotated_response).clone()), None);
1130    }
1131    let (Some(codec), Some(response)) = (response_codec, data) else {
1132        return (None, None);
1133    };
1134    match codec.decode_response(response) {
1135        Ok(mut decoded) => {
1136            if behavior.attach_estimated_cost {
1137                attach_estimated_cost_for_provider(&mut decoded, Some(provider_name));
1138            }
1139            (Some(decoded), None)
1140        }
1141        Err(error) => (None, Some(error)),
1142    }
1143}
1144
1145async fn emit_llm_end_without_output(
1146    handle: &LlmHandle,
1147    metadata: Option<Json>,
1148    response_codec: Option<Arc<dyn LlmResponseCodec>>,
1149    lifecycle_subscribers: Option<&[EventSubscriberFn]>,
1150) -> Result<()> {
1151    ensure_runtime_owner()?;
1152    let (entries, subscribers) = {
1153        let scope_stack = handle.captured_scope_stack();
1154        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1155        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1156            &registries.llm_sanitize_response_guardrails
1157        });
1158        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
1159        let subscribers = match lifecycle_subscribers {
1160            Some(subscribers) => subscribers.to_vec(),
1161            None => snapshot_event_subscribers(scope_subscribers)?,
1162        };
1163        let context = global_context();
1164        let state = context
1165            .read()
1166            .map_err(|error| FlowError::Internal(error.to_string()))?;
1167        let entries = state.llm_sanitize_response_entries(&scope_locals);
1168        (entries, subscribers)
1169    };
1170    let had_fallback_data = handle.data.is_some();
1171    let data = if let Some(data) = handle.data.clone() {
1172        NemoRelayContextState::llm_sanitize_response_snapshot_chain(
1173            data,
1174            LlmSanitizeResponseContext::for_response_codec(response_codec),
1175            &entries,
1176        )
1177        .await
1178    } else {
1179        None
1180    };
1181    let annotation_omitted =
1182        (had_fallback_data && data.is_none()) || data.as_ref().is_some_and(Json::is_null);
1183    handle.optimization_recorder.close_for_finalization(None);
1184    emit_optimization_marks(handle, &subscribers).await;
1185    let pricing = crate::codec::response::active_pricing_resolver();
1186    let annotated_response = (!annotation_omitted)
1187        .then(|| {
1188            finalize_optimization_summary(
1189                &handle.optimization_recorder,
1190                None,
1191                handle.model_name.as_deref(),
1192                &pricing,
1193            )
1194        })
1195        .flatten()
1196        .map(|summary| {
1197            Arc::new(AnnotatedLlmResponse {
1198                optimization_summary: Some(summary),
1199                ..AnnotatedLlmResponse::default()
1200            })
1201        });
1202    let event = {
1203        let context = global_context();
1204        let state = context
1205            .read()
1206            .map_err(|error| FlowError::Internal(error.to_string()))?;
1207        state.end_llm_handle(handle, data, metadata, annotated_response)
1208    };
1209    queue_sanitized_event_with_scope_stack(event, &subscribers, handle.captured_scope_stack());
1210    Ok(())
1211}
1212
1213struct ManagedLlmCompletion {
1214    handle: Option<LlmHandle>,
1215    metadata: Option<Json>,
1216    response_codec: Option<Arc<dyn LlmResponseCodec>>,
1217    subscribers: Vec<EventSubscriberFn>,
1218    pending_publication: Option<PendingPublication>,
1219}
1220
1221impl ManagedLlmCompletion {
1222    fn new(
1223        handle: &LlmHandle,
1224        metadata: Option<Json>,
1225        response_codec: Option<Arc<dyn LlmResponseCodec>>,
1226        subscribers: &[EventSubscriberFn],
1227    ) -> Self {
1228        Self {
1229            handle: Some(handle.clone()),
1230            metadata,
1231            response_codec,
1232            subscribers: subscribers.to_vec(),
1233            pending_publication: (!subscribers.is_empty())
1234                .then(register_pending_publication)
1235                .flatten(),
1236        }
1237    }
1238
1239    fn disarm(&mut self) {
1240        self.handle = None;
1241        drop(self.pending_publication.take());
1242    }
1243}
1244
1245impl Drop for ManagedLlmCompletion {
1246    fn drop(&mut self) {
1247        let pending_publication = self.pending_publication.take();
1248        let Some(handle) = self.handle.take() else {
1249            return;
1250        };
1251        let metadata = metadata_with_otel_status(
1252            self.metadata.take(),
1253            "ERROR",
1254            Some("LLM execution cancelled".into()),
1255        );
1256        let scope_stack = handle.captured_scope_stack().clone();
1257        let entries = match scope_stack.read() {
1258            Ok(scope_guard) => {
1259                let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1260                    &registries.llm_sanitize_response_guardrails
1261                });
1262                global_context()
1263                    .read()
1264                    .map(|state| state.llm_sanitize_response_entries(&scope_locals))
1265                    .unwrap_or_default()
1266            }
1267            Err(_) => Vec::new(),
1268        };
1269        handle
1270            .optimization_recorder
1271            .close_for_finalization(Some("execution_cancelled"));
1272        enqueue_optimization_marks(&handle, &self.subscribers);
1273        let event = global_context()
1274            .read()
1275            .ok()
1276            .map(|state| state.end_llm_handle(&handle, None, metadata.clone(), None));
1277        let Some(event) = event else {
1278            return;
1279        };
1280        let event_sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
1281        let response_codec = self.response_codec.take();
1282        let subscribers = std::mem::take(&mut self.subscribers);
1283        let fallback_data = handle.data.clone();
1284        dispatch_transformed_event(
1285            event,
1286            Box::new(move |event| {
1287                Box::pin(async move {
1288                    let Some(data) = fallback_data else {
1289                        return event;
1290                    };
1291                    let data = NemoRelayContextState::llm_sanitize_response_snapshot_chain(
1292                        data,
1293                        LlmSanitizeResponseContext::for_response_codec(response_codec),
1294                        &entries,
1295                    )
1296                    .await;
1297                    let annotation_omitted = data.as_ref().is_none_or(Json::is_null);
1298                    let annotated_response = (!annotation_omitted)
1299                        .then(|| {
1300                            let pricing = crate::codec::response::active_pricing_resolver();
1301                            finalize_optimization_summary(
1302                                &handle.optimization_recorder,
1303                                None,
1304                                handle.model_name.as_deref(),
1305                                &pricing,
1306                            )
1307                        })
1308                        .flatten()
1309                        .map(|summary| {
1310                            Arc::new(AnnotatedLlmResponse {
1311                                optimization_summary: Some(summary),
1312                                ..AnnotatedLlmResponse::default()
1313                            })
1314                        });
1315                    global_context()
1316                        .read()
1317                        .map(|state| {
1318                            state.end_llm_handle(&handle, data, metadata, annotated_response)
1319                        })
1320                        .unwrap_or(event)
1321                })
1322            }),
1323            event_sanitizers,
1324            &subscribers,
1325            scope_stack,
1326        );
1327        drop(pending_publication);
1328    }
1329}
1330
1331/// Execute an LLM call through the managed middleware pipeline.
1332///
1333/// This runs conditional-execution guardrails, request intercepts, and
1334/// sanitize-request guardrails, emits the LLM-start event, then runs execution
1335/// intercepts, the provider callback when it is not replaced, and
1336/// sanitize-response guardrails in the runtime-defined order.
1337///
1338/// # Parameters
1339/// - `name`: Logical provider or model family name recorded on emitted events.
1340/// - `request`: Raw [`LlmRequest`] passed into the managed pipeline.
1341/// - `func`: Provider callback or execution continuation.
1342/// - `parent`: Optional explicit parent scope for the emitted LLM span.
1343/// - `attributes`: LLM attribute bitflags applied to the managed span.
1344/// - `data`: Optional application payload stored on the managed LLM handle. It
1345///   may be used on failure end events that have no output payload.
1346/// - `metadata`: Optional JSON metadata recorded on emitted events.
1347/// - `model_name`: Optional normalized model name for observability output.
1348/// - `codec`: Optional request codec used to produce annotated request data for
1349///   intercepts and events.
1350/// - `response_codec`: Optional response codec used to attach annotated
1351///   response data to the end event.
1352///
1353/// # Returns
1354/// A [`Result`] containing the raw JSON response returned by the callback or
1355/// an execution intercept.
1356///
1357/// # Errors
1358/// Returns [`FlowError::GuardrailRejected`] when conditional-execution
1359/// guardrails block the call, or any error raised by request intercepts,
1360/// execution intercepts, codecs, or the callback itself.
1361///
1362/// # Notes
1363/// The LLM-start event is emitted before execution intercepts run. Before
1364/// sanitize-request guardrails run, the runtime removes standard credential
1365/// headers from the event-only request copy; the request passed to execution is
1366/// unchanged. When execution fails after that point, the runtime still emits an
1367/// LLM-end event without an output payload.
1368///
1369/// Response codecs enrich observability output only and do not change the
1370/// value returned to the caller.
1371pub async fn llm_call_execute(params: LlmCallExecuteParams) -> Result<Json> {
1372    let LlmCallExecuteParams {
1373        name,
1374        request,
1375        func,
1376        parent,
1377        attributes,
1378        data,
1379        metadata,
1380        model_name,
1381        codec,
1382        response_codec,
1383    } = params;
1384    ensure_runtime_owner()?;
1385    {
1386        let (entries, subscribers, parent_uuid, guardrail_metadata) = {
1387            let scope_stack = current_scope_stack();
1388            let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1389            let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1390                &registries.llm_conditional_execution_guardrails
1391            });
1392            let scope_subscribers = scope_guard.collect_scope_local_subscribers();
1393            let context = global_context();
1394            let state = context
1395                .read()
1396                .map_err(|error| FlowError::Internal(error.to_string()))?;
1397            let entries = state.llm_conditional_execution_entries(&scope_locals);
1398            let subscribers = state.collect_event_subscribers(&scope_subscribers);
1399            (
1400                entries,
1401                subscribers,
1402                resolve_parent_uuid(parent.as_ref()),
1403                metadata.clone(),
1404            )
1405        };
1406        if let Some(error) = NemoRelayContextState::llm_conditional_execution_snapshot_chain(
1407            &request,
1408            &entries,
1409            &subscribers,
1410            parent_uuid,
1411            guardrail_metadata,
1412        )
1413        .await?
1414        {
1415            let mut rejection_data = json!({});
1416            if let Some(object) = rejection_data.as_object_mut() {
1417                object.insert("rejected".into(), json!(true));
1418                object.insert("rejection_reason".into(), json!(&error));
1419            }
1420            let _ = event(
1421                EmitMarkEventParams::builder()
1422                    .name(&name)
1423                    .parent_opt(parent.as_ref())
1424                    .data(rejection_data)
1425                    .metadata_opt(metadata.clone())
1426                    .build(),
1427            );
1428            return Err(FlowError::GuardrailRejected(error));
1429        }
1430    }
1431
1432    let request_codec = codec.clone();
1433    let optimization_recorder = LlmOptimizationRecorder::default();
1434    let (intercepted_request, annotated_request, pending_marks, optimization_contributions) =
1435        scope_llm_optimization_recorder(optimization_recorder.clone(), async {
1436            run_request_intercepts_with_codec_and_recorder(
1437                &name,
1438                request,
1439                codec,
1440                &optimization_recorder,
1441            )
1442            .await
1443        })
1444        .await?;
1445
1446    let mut handle = create_llm_handle(
1447        CreateLlmHandleParams::builder()
1448            .name(name.as_str())
1449            .parent_uuid_opt(resolve_parent_uuid(parent.as_ref()))
1450            .attributes(attributes)
1451            .data_opt(data.clone())
1452            .metadata_opt(metadata.clone())
1453            .model_name_opt(model_name)
1454            .build(),
1455    )?;
1456    handle.optimization_recorder = optimization_recorder;
1457    let lifecycle_subscribers = {
1458        let scope_stack = handle.captured_scope_stack();
1459        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1460        snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?
1461    };
1462    emit_llm_start_with_subscribers(
1463        &handle,
1464        &intercepted_request,
1465        annotated_request.clone(),
1466        request_codec.clone(),
1467        &lifecycle_subscribers,
1468    )
1469    .await?;
1470    emit_pending_request_marks(&handle, pending_marks, &lifecycle_subscribers).await?;
1471    handle
1472        .optimization_recorder
1473        .record_all(optimization_contributions);
1474    emit_optimization_marks(&handle, &lifecycle_subscribers).await;
1475
1476    let mut completion = ManagedLlmCompletion::new(
1477        &handle,
1478        metadata.clone(),
1479        response_codec.clone(),
1480        &lifecycle_subscribers,
1481    );
1482    let execution_name = name.clone();
1483    let event_uuid = handle.uuid;
1484    let execution = with_active_event_uuid(
1485        event_uuid,
1486        scope_llm_optimization_recorder(handle.optimization_recorder.clone(), async move {
1487            let execution = {
1488                let scope_stack = current_scope_stack();
1489                let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1490                let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1491                    &registries.llm_execution_intercepts
1492                });
1493                let context = global_context();
1494                let state = context
1495                    .read()
1496                    .map_err(|error| FlowError::Internal(error.to_string()))?;
1497                state.llm_build_execution_chain(&execution_name, func, &scope_locals)
1498            };
1499            execution(intercepted_request).await
1500        }),
1501    )
1502    .await;
1503
1504    match execution {
1505        Ok(response) => {
1506            llm_call_end_with_behavior(
1507                LlmCallEndParams::builder()
1508                    .handle(&handle)
1509                    .response(response.clone())
1510                    .data_opt(data)
1511                    .metadata_opt(metadata)
1512                    .response_codec_opt(response_codec)
1513                    .build(),
1514                LlmCallEndBehavior {
1515                    response_codec_errors_fatal: false,
1516                    attach_estimated_cost: true,
1517                },
1518                Some(&lifecycle_subscribers),
1519            )
1520            .await?;
1521            completion.disarm();
1522            Ok(response)
1523        }
1524        Err(error) => {
1525            let end_metadata = metadata_with_otel_error(metadata, &error);
1526            let _ = emit_llm_end_without_output(
1527                &handle,
1528                end_metadata,
1529                response_codec,
1530                Some(&lifecycle_subscribers),
1531            )
1532            .await;
1533            completion.disarm();
1534            Err(error)
1535        }
1536    }
1537}
1538
1539/// Execute a streaming LLM call through the managed middleware pipeline.
1540///
1541/// This runs the same pre-execution middleware as [`llm_call_execute`], emits
1542/// the LLM-start event, and then wraps the provider stream so chunk callbacks
1543/// and finalization can emit a single LLM-end event when streaming completes.
1544///
1545/// # Parameters
1546/// - `name`: Logical provider or model family name recorded on emitted events.
1547/// - `request`: Raw [`LlmRequest`] passed into the managed pipeline.
1548/// - `func`: Streaming provider callback or execution continuation.
1549/// - `collector`: Per-chunk collector callback used to accumulate stream state.
1550/// - `finalizer`: Finalizer callback used to construct the completed response.
1551/// - `parent`: Optional explicit parent scope for the emitted LLM span.
1552/// - `attributes`: LLM attribute bitflags applied to the managed span.
1553/// - `data`: Optional application payload stored on the managed LLM handle. It
1554///   may be used on failure end events that have no output payload.
1555/// - `metadata`: Optional JSON metadata recorded on emitted events.
1556/// - `model_name`: Optional normalized model name for observability output.
1557/// - `codec`: Optional request codec used to produce annotated request data for
1558///   intercepts and events.
1559/// - `response_codec`: Optional response codec used to attach annotated
1560///   response data to the end event.
1561///
1562/// # Returns
1563/// A [`Result`] containing a boxed stream of JSON chunks.
1564///
1565/// # Errors
1566/// Returns [`FlowError::GuardrailRejected`] when conditional-execution
1567/// guardrails block the call, or any error raised by request intercepts,
1568/// execution intercepts, stream callbacks, codecs, or the provider callback.
1569///
1570/// # Notes
1571/// The LLM-start event is emitted before stream execution intercepts run.
1572/// Before sanitize-request guardrails run, the runtime removes standard
1573/// credential headers from the event-only request copy; the request passed to
1574/// stream execution is unchanged.
1575///
1576/// The returned stream emits chunk-level results while the runtime defers the
1577/// LLM-end event until the collector and finalizer complete.
1578pub async fn llm_stream_call_execute(params: LlmStreamCallExecuteParams) -> Result<LlmJsonStream> {
1579    let LlmStreamCallExecuteParams {
1580        name,
1581        request,
1582        func,
1583        collector,
1584        finalizer,
1585        parent,
1586        attributes,
1587        data,
1588        metadata,
1589        model_name,
1590        codec,
1591        response_codec,
1592    } = params;
1593    ensure_runtime_owner()?;
1594    {
1595        let (entries, subscribers, parent_uuid, guardrail_metadata) = {
1596            let scope_stack = current_scope_stack();
1597            let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1598            let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1599                &registries.llm_conditional_execution_guardrails
1600            });
1601            let scope_subscribers = scope_guard.collect_scope_local_subscribers();
1602            let context = global_context();
1603            let state = context
1604                .read()
1605                .map_err(|error| FlowError::Internal(error.to_string()))?;
1606            let entries = state.llm_conditional_execution_entries(&scope_locals);
1607            let subscribers = state.collect_event_subscribers(&scope_subscribers);
1608            (
1609                entries,
1610                subscribers,
1611                resolve_parent_uuid(parent.as_ref()),
1612                metadata.clone(),
1613            )
1614        };
1615        if let Some(error) = NemoRelayContextState::llm_conditional_execution_snapshot_chain(
1616            &request,
1617            &entries,
1618            &subscribers,
1619            parent_uuid,
1620            guardrail_metadata,
1621        )
1622        .await?
1623        {
1624            let mut rejection_data = json!({});
1625            if let Some(object) = rejection_data.as_object_mut() {
1626                object.insert("rejected".into(), json!(true));
1627                object.insert("rejection_reason".into(), json!(&error));
1628            }
1629            let _ = event(
1630                EmitMarkEventParams::builder()
1631                    .name(&name)
1632                    .parent_opt(parent.as_ref())
1633                    .data(rejection_data)
1634                    .metadata_opt(metadata.clone())
1635                    .build(),
1636            );
1637            return Err(FlowError::GuardrailRejected(error));
1638        }
1639    }
1640
1641    let request_codec = codec.clone();
1642    let optimization_recorder = LlmOptimizationRecorder::default();
1643    let (intercepted_request, annotated_request, pending_marks, optimization_contributions) =
1644        scope_llm_optimization_recorder(optimization_recorder.clone(), async {
1645            run_request_intercepts_with_codec_and_recorder(
1646                &name,
1647                request,
1648                codec,
1649                &optimization_recorder,
1650            )
1651            .await
1652        })
1653        .await?;
1654
1655    let mut handle = create_llm_handle(
1656        CreateLlmHandleParams::builder()
1657            .name(name.as_str())
1658            .parent_uuid_opt(resolve_parent_uuid(parent.as_ref()))
1659            .attributes(attributes)
1660            .data_opt(data.clone())
1661            .metadata_opt(metadata.clone())
1662            .model_name_opt(model_name)
1663            .build(),
1664    )?;
1665    handle.optimization_recorder = optimization_recorder;
1666    let lifecycle_subscribers = {
1667        let scope_stack = handle.captured_scope_stack();
1668        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1669        snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?
1670    };
1671    emit_llm_start_with_subscribers(
1672        &handle,
1673        &intercepted_request,
1674        annotated_request,
1675        request_codec.clone(),
1676        &lifecycle_subscribers,
1677    )
1678    .await?;
1679    emit_pending_request_marks(&handle, pending_marks, &lifecycle_subscribers).await?;
1680    handle
1681        .optimization_recorder
1682        .record_all(optimization_contributions);
1683    emit_optimization_marks(&handle, &lifecycle_subscribers).await;
1684
1685    let mut completion = ManagedLlmCompletion::new(
1686        &handle,
1687        metadata.clone(),
1688        response_codec.clone(),
1689        &lifecycle_subscribers,
1690    );
1691    let execution_name = name.clone();
1692    let event_uuid = handle.uuid;
1693    let execution = with_active_event_uuid(
1694        event_uuid,
1695        scope_llm_optimization_recorder(handle.optimization_recorder.clone(), async move {
1696            let execution = {
1697                let scope_stack = current_scope_stack();
1698                let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1699                let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1700                    &registries.llm_stream_execution_intercepts
1701                });
1702                let context = global_context();
1703                let state = context
1704                    .read()
1705                    .map_err(|error| FlowError::Internal(error.to_string()))?;
1706                state.llm_stream_build_execution_chain(&execution_name, func, &scope_locals)
1707            };
1708            let execution_context = MiddlewareContinuationContext::capture();
1709            execution(intercepted_request)
1710                .await
1711                .map(|stream| contextualize_stream(stream, execution_context))
1712        }),
1713    )
1714    .await;
1715
1716    match execution {
1717        Ok(raw_stream) => {
1718            let wrapper = LlmStreamWrapper::new_managed(
1719                raw_stream,
1720                handle,
1721                collector,
1722                finalizer,
1723                metadata,
1724                response_codec,
1725                lifecycle_subscribers,
1726            );
1727            completion.disarm();
1728            Ok(LlmJsonStream::from_closeable(wrapper))
1729        }
1730        Err(error) => {
1731            let end_metadata = metadata_with_otel_error(metadata, &error);
1732            let _ = emit_llm_end_without_output(
1733                &handle,
1734                end_metadata,
1735                response_codec,
1736                Some(&lifecycle_subscribers),
1737            )
1738            .await;
1739            completion.disarm();
1740            Err(error)
1741        }
1742    }
1743}
1744
1745/// Run only the LLM request-intercept chain.
1746///
1747/// This applies the currently active global and scope-local request intercepts
1748/// without emitting lifecycle events or invoking provider execution.
1749///
1750/// # Parameters
1751/// - `name`: Logical provider or model family name used when resolving the
1752///   intercept chain.
1753/// - `request`: Raw [`LlmRequest`] to transform.
1754///
1755/// # Returns
1756/// A [`Result`] containing the transformed [`LlmRequest`].
1757///
1758/// # Errors
1759/// Returns any error raised by the request-intercept chain.
1760///
1761/// # Notes
1762/// Conditional guardrails, codecs, and execution intercepts are not run by
1763/// this helper.
1764/// Run the LLM request-intercept chain and return its complete outcome.
1765///
1766/// This helper does not emit the returned marks because it does not own an LLM
1767/// lifecycle. Callers must attach them to the lifecycle they own.
1768pub async fn llm_request_intercepts(
1769    name: &str,
1770    request: LlmRequest,
1771) -> Result<LlmRequestInterceptOutcome> {
1772    ensure_runtime_owner()?;
1773    let entries = {
1774        let scope_stack = current_scope_stack();
1775        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1776        let scope_locals = scope_guard
1777            .collect_scope_local_registries(|registries| &registries.llm_request_intercepts);
1778        let context = global_context();
1779        let state = context
1780            .read()
1781            .map_err(|error| FlowError::Internal(error.to_string()))?;
1782        state.llm_request_intercept_entries(&scope_locals)
1783    };
1784    let mut outcome = NemoRelayContextState::llm_request_intercepts_snapshot_chain(
1785        name, request, None, &entries, false,
1786    )
1787    .await?;
1788    inject_dynamo_session_ids(&mut outcome.request);
1789    Ok(outcome)
1790}
1791
1792/// Run only the LLM conditional-execution guardrail chain.
1793///
1794/// This evaluates whether an LLM call should be allowed to proceed without
1795/// invoking request intercepts or execution. Each evaluated guardrail emits an
1796/// automatic guardrail scope start/end pair for observability.
1797///
1798/// # Parameters
1799/// - `request`: Raw [`LlmRequest`] to validate.
1800///
1801/// # Returns
1802/// A [`Result`] that is `Ok(())` when all guardrails allow execution.
1803///
1804/// # Errors
1805/// Returns [`FlowError::GuardrailRejected`] when a guardrail blocks execution,
1806/// or any error raised by the guardrail chain itself.
1807///
1808/// # Notes
1809/// This helper is useful for preflight checks when the caller needs the
1810/// rejection result without starting an LLM span. Guardrail scopes are still
1811/// emitted for the conditional checks themselves.
1812pub async fn llm_conditional_execution(request: &LlmRequest) -> Result<()> {
1813    ensure_runtime_owner()?;
1814    let (entries, subscribers, parent_uuid) = {
1815        let scope_stack = current_scope_stack();
1816        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1817        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1818            &registries.llm_conditional_execution_guardrails
1819        });
1820        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
1821        let context = global_context();
1822        let state = context
1823            .read()
1824            .map_err(|error| FlowError::Internal(error.to_string()))?;
1825        let entries = state.llm_conditional_execution_entries(&scope_locals);
1826        let subscribers = state.collect_event_subscribers(&scope_subscribers);
1827        (entries, subscribers, resolve_parent_uuid(None))
1828    };
1829    if let Some(error) = NemoRelayContextState::llm_conditional_execution_snapshot_chain(
1830        request,
1831        &entries,
1832        &subscribers,
1833        parent_uuid,
1834        None,
1835    )
1836    .await?
1837    {
1838        return Err(FlowError::GuardrailRejected(error));
1839    }
1840    Ok(())
1841}
1842
1843#[cfg(test)]
1844#[path = "../../tests/unit/llm_api_tests.rs"]
1845mod tests;