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, full_payloads_enabled) = {
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        (
437            state.llm_sanitize_request_entries(&scope_locals),
438            state.observability_full_payloads_enabled,
439        )
440    };
441    let observable_request = remove_observability_credential_headers(request.clone());
442    let mut sanitized_request = NemoRelayContextState::llm_sanitize_request_snapshot_chain(
443        observable_request.clone(),
444        LlmSanitizeRequestContext::for_request_codec(request_codec.clone()),
445        &entries,
446    )
447    .await;
448    let request_changed = sanitized_request
449        .as_ref()
450        .is_some_and(|sanitized_request| sanitized_request != &observable_request);
451    let mut annotated_request = match (sanitized_request.as_ref(), request_codec.as_deref()) {
452        (Some(sanitized_request), Some(codec)) if request_changed => {
453            codec.decode(sanitized_request).ok().map(Arc::new)
454        }
455        (Some(_), _) if !request_changed => annotated_request,
456        (None, _) => None,
457        (Some(_), _) => None,
458    };
459    let scope_stack = handle.captured_scope_stack();
460    let agent_is_fresh = {
461        let mut scope_guard = scope_stack.write().expect("scope stack lock poisoned");
462        scope_guard.take_agent_freshness(handle.parent_uuid)
463    };
464    if !full_payloads_enabled
465        && !agent_is_fresh
466        && let Some(sanitized_request) = sanitized_request.as_mut()
467    {
468        project_llm_request_to_current_user_turn(
469            sanitized_request,
470            &mut annotated_request,
471            request_codec.as_deref(),
472        );
473    }
474    let input = sanitized_request
475        .as_ref()
476        .and_then(|sanitized_request| serde_json::to_value(sanitized_request).ok());
477    let event = {
478        let context = global_context();
479        let state = context
480            .read()
481            .map_err(|error| FlowError::Internal(error.to_string()))?;
482        state.build_llm_start_event(handle, input, annotated_request)
483    };
484    queue_sanitized_event_with_scope_stack(event, subscribers, scope_stack);
485    Ok(())
486}
487
488fn remove_observability_credential_headers(mut request: LlmRequest) -> LlmRequest {
489    request.headers.retain(|name, _| {
490        !OBSERVABILITY_CREDENTIAL_HEADERS
491            .iter()
492            .any(|credential_header| name.eq_ignore_ascii_case(credential_header))
493    });
494    request
495}
496
497/// Synchronous test seam retained for lifecycle unit tests. Public manual
498/// lifecycle emission is synchronous too, but its work is queued; this helper
499/// exercises the managed start-event transformation directly.
500#[cfg(test)]
501fn emit_llm_start(
502    handle: &LlmHandle,
503    request: &LlmRequest,
504    annotated_request: Option<Arc<AnnotatedLlmRequest>>,
505    request_codec: Option<Arc<dyn LlmCodec>>,
506) -> Result<()> {
507    let subscribers = {
508        let scope_stack = handle.captured_scope_stack();
509        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
510        snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?
511    };
512    crate::api::runtime::subscriber_dispatcher::block_on_sanitizer_future(
513        emit_llm_start_with_subscribers(
514            handle,
515            request,
516            annotated_request,
517            request_codec,
518            &subscribers,
519        ),
520    )
521    .map_err(FlowError::Internal)?
522}
523
524async fn emit_pending_request_marks(
525    handle: &LlmHandle,
526    marks: Vec<PendingMarkSpec>,
527    subscribers: &[EventSubscriberFn],
528) -> Result<()> {
529    if marks.is_empty() {
530        return Ok(());
531    }
532    ensure_runtime_owner()?;
533    let timestamp = handle.started_at + TimeDelta::microseconds(1);
534    for mark in marks {
535        let event = Event::Mark(MarkEvent::new(
536            BaseEvent::builder()
537                .name(mark.name)
538                .parent_uuid(handle.uuid)
539                .timestamp(timestamp)
540                .data_opt(mark.data)
541                .metadata_opt(mark.metadata)
542                .build(),
543            mark.category,
544            mark.category_profile,
545        ));
546        queue_sanitized_event_with_scope_stack(event, subscribers, handle.captured_scope_stack());
547    }
548    Ok(())
549}
550
551pub(crate) async fn emit_optimization_marks(handle: &LlmHandle, subscribers: &[EventSubscriberFn]) {
552    emit_optimization_marks_with_async(
553        handle,
554        subscribers,
555        |event| async { Some(event) },
556        |event, subscribers| {
557            queue_sanitized_event_with_scope_stack(
558                event.clone(),
559                subscribers,
560                handle.captured_scope_stack(),
561            )
562        },
563    )
564    .await;
565}
566
567pub(crate) async fn emit_reserved_optimization_marks(
568    handle: &LlmHandle,
569    subscribers: &[EventSubscriberFn],
570) {
571    emit_optimization_marks_with_async(
572        handle,
573        subscribers,
574        |event| async { Some(event) },
575        |event, subscribers| {
576            let sanitizers =
577                snapshot_event_sanitizers(event, handle.captured_scope_stack()).unwrap_or_default();
578            dispatch_reserved_sanitized_event(
579                event.clone(),
580                sanitizers,
581                subscribers,
582                handle.captured_scope_stack().clone(),
583            )
584        },
585    )
586    .await;
587}
588
589/// Queue optimization marks from a synchronous lifecycle API.
590///
591/// The public manual lifecycle APIs must not await middleware. Capture each
592/// event's sanitizer chain now and enqueue the immutable snapshots ahead of
593/// the corresponding end event, preserving publication order.
594fn enqueue_optimization_marks(handle: &LlmHandle, subscribers: &[EventSubscriberFn]) {
595    let contributions = handle.optimization_recorder.unemitted_with_timestamps();
596    if contributions.is_empty() || ensure_runtime_owner().is_err() {
597        return;
598    }
599    let scope_stack = handle.captured_scope_stack().clone();
600    for (contribution, recorded_at) in contributions {
601        let event = optimization_mark_event(handle, &contribution, recorded_at);
602        let sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
603        if dispatch_sanitized_event(event, sanitizers, subscribers, scope_stack.clone()) {
604            handle.optimization_recorder.mark_emitted(1);
605        } else {
606            break;
607        }
608    }
609}
610
611async fn emit_optimization_marks_with_async<F, Fut>(
612    handle: &LlmHandle,
613    subscribers: &[EventSubscriberFn],
614    mut sanitize: F,
615    mut enqueue: impl FnMut(&Event, &[EventSubscriberFn]) -> bool,
616) where
617    F: FnMut(Event) -> Fut,
618    Fut: Future<Output = Option<Event>>,
619{
620    let contributions = handle.optimization_recorder.unemitted_with_timestamps();
621    if contributions.is_empty() {
622        return;
623    }
624    if ensure_runtime_owner().is_err() {
625        log::warn!(
626            target: "nemo_relay.observability",
627            event = "optimization_marks_skipped",
628            reason = "runtime_owner_unavailable",
629            contribution_count = contributions.len();
630            "LLM optimization marks were skipped"
631        );
632        return;
633    }
634    for (contribution, recorded_at) in contributions {
635        let event = optimization_mark_event(handle, &contribution, recorded_at);
636        let Some(event) = sanitize(event).await else {
637            // Sanitizers currently rewrite fields rather than intentionally
638            // dropping events. `None` means the sanitizer context was
639            // unavailable, so preserve this ordered suffix for a later retry.
640            break;
641        };
642        if enqueue(&event, subscribers) {
643            handle.optimization_recorder.mark_emitted(1);
644        } else {
645            // Preserve this item and the remaining ordered suffix for a later
646            // lifecycle boundary. Accounting remains best effort and must not
647            // alter the provider result.
648            break;
649        }
650    }
651}
652
653fn optimization_mark_event(
654    handle: &LlmHandle,
655    contribution: &crate::codec::optimization::LlmOptimizationContribution,
656    recorded_at: DateTime<Utc>,
657) -> Event {
658    let offset = contribution.sequence.unwrap_or(0).saturating_add(2);
659    let offset = i64::try_from(offset).unwrap_or(i64::MAX);
660    let request_ordered_timestamp = handle.started_at + TimeDelta::microseconds(offset);
661    Event::Mark(MarkEvent::new(
662        BaseEvent::builder()
663            .name("nemo_relay.llm.optimization")
664            .parent_uuid(handle.uuid)
665            .timestamp(recorded_at.max(request_ordered_timestamp))
666            .data(serde_json::to_value(contribution).unwrap_or(Json::Null))
667            .data_schema(DataSchema {
668                name: "nemo.relay.llm_optimization_contribution".to_string(),
669                version: "1".to_string(),
670            })
671            .build(),
672        Some(EventCategory::custom()),
673        Some(
674            CategoryProfile::builder()
675                .subtype("nemo_relay.llm.optimization")
676                .build(),
677        ),
678    ))
679}
680
681/// Synchronous test seam for optimization-mark accounting. Production paths
682/// always use [`emit_optimization_marks_with_async`]; unit tests use this seam
683/// to isolate cursor behavior from asynchronous event publication.
684#[cfg(test)]
685fn emit_optimization_marks_with<F>(
686    handle: &LlmHandle,
687    subscribers: &[EventSubscriberFn],
688    mut sanitize: F,
689    mut enqueue: impl FnMut(&Event, &[EventSubscriberFn]) -> bool,
690) where
691    F: FnMut(Event) -> Option<Event>,
692{
693    let contributions = handle.optimization_recorder.unemitted_with_timestamps();
694    if contributions.is_empty() || ensure_runtime_owner().is_err() {
695        return;
696    }
697    for (contribution, recorded_at) in contributions {
698        let event = optimization_mark_event(handle, &contribution, recorded_at);
699        let Some(event) = sanitize(event) else {
700            break;
701        };
702        if enqueue(&event, subscribers) {
703            handle.optimization_recorder.mark_emitted(1);
704        } else {
705            break;
706        }
707    }
708}
709
710/// Start a manual LLM lifecycle span.
711///
712/// This emits an LLM-start event after applying sanitize-request guardrails to
713/// the payload recorded for observability.
714///
715/// If a sanitizer errors or panics, Relay omits the payload and request
716/// annotation and does not run remaining sanitizers.
717///
718/// # Parameters
719/// - `name`: Logical provider or model family name recorded on the span.
720/// - `request`: Raw [`LlmRequest`] associated with the span.
721/// - `parent`: Optional explicit parent scope.
722/// - `attributes`: LLM attribute bitflags applied to the span.
723/// - `data`: Optional application payload stored on the returned handle. The
724///   emitted start event data is the sanitized `request` payload.
725/// - `metadata`: Optional JSON metadata recorded on the start event.
726/// - `model_name`: Optional normalized model name recorded separately from the
727///   request payload.
728/// - `annotated_request`: Optional normalized request annotation produced by a
729///   codec.
730/// - `timestamp`: Optional timestamp recorded as the handle start time and on
731///   the emitted start event. When `None`, the current UTC time is used.
732///
733/// # Returns
734/// A [`Result`] containing the created [`LlmHandle`] after its start-event
735/// snapshot has been submitted for queued publication.
736///
737/// # Errors
738/// Returns an error when the runtime owner check fails or when internal state
739/// cannot be read safely. Dispatcher submission failures are logged because
740/// observability publication is best effort.
741///
742/// # Notes
743/// The runtime removes standard credential headers (`authorization`,
744/// `proxy-authorization`, `cookie`, `x-api-key`, `api-key`,
745/// `anthropic-api-key`, and `x-goog-api-key`) from the event-only request copy
746/// before sanitize-request guardrails run. This does not change the
747/// caller-owned [`LlmRequest`]. By default, when the owning agent is not fresh,
748/// the emitted request annotation is limited to the current user turn. Managed
749/// calls with a request codec also apply that projection to the event input,
750/// without changing the request used for provider execution. The observability
751/// plugin's `enable_full_payloads` option disables this projection.
752pub fn llm_call(params: LlmCallParams<'_>) -> Result<LlmHandle> {
753    let handle_params = CreateLlmHandleParams::builder()
754        .name(params.name)
755        .parent_uuid_opt(resolve_parent_uuid(params.parent))
756        .attributes(params.attributes)
757        .data_opt(params.data)
758        .metadata_opt(params.metadata)
759        .model_name_opt(params.model_name)
760        .timestamp_opt(params.timestamp)
761        .build();
762    let handle = create_llm_handle(handle_params)?;
763    let scope_stack = handle.captured_scope_stack().clone();
764    let (entries, subscribers, agent_is_fresh, full_payloads_enabled) = {
765        let mut scope_guard = scope_stack
766            .write()
767            .map_err(|error| FlowError::Internal(error.to_string()))?;
768        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
769            &registries.llm_sanitize_request_guardrails
770        });
771        let subscribers =
772            snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?;
773        let context = global_context();
774        let state = context
775            .read()
776            .map_err(|error| FlowError::Internal(error.to_string()))?;
777        let entries = state.llm_sanitize_request_entries(&scope_locals);
778        let full_payloads_enabled = state.observability_full_payloads_enabled;
779        drop(state);
780        let agent_is_fresh = scope_guard.take_agent_freshness(handle.parent_uuid);
781        (entries, subscribers, agent_is_fresh, full_payloads_enabled)
782    };
783    // Middleware and event publication only observe a credential-free copy.
784    // Keep `params.request` untouched: it remains the caller/provider request.
785    let request = remove_observability_credential_headers(params.request.clone());
786    let annotated_request = params.annotated_request;
787    let event = {
788        let context = global_context();
789        let state = context
790            .read()
791            .map_err(|error| FlowError::Internal(error.to_string()))?;
792        state.build_llm_start_event(&handle, None, None)
793    };
794    let queued_handle = handle.clone();
795    let event_sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
796    dispatch_transformed_event(
797        event,
798        Box::new(move |event| {
799            Box::pin(async move {
800                let mut sanitized_request =
801                    NemoRelayContextState::llm_sanitize_request_snapshot_chain(
802                        request.clone(),
803                        LlmSanitizeRequestContext::default(),
804                        &entries,
805                    )
806                    .await;
807                let request_changed = sanitized_request
808                    .as_ref()
809                    .is_some_and(|sanitized| sanitized != &request);
810                let mut annotation = if sanitized_request.is_none() || request_changed {
811                    None
812                } else {
813                    annotated_request
814                };
815                if !full_payloads_enabled
816                    && !agent_is_fresh
817                    && let Some(sanitized_request) = sanitized_request.as_mut()
818                {
819                    project_llm_request_to_current_user_turn(
820                        sanitized_request,
821                        &mut annotation,
822                        None,
823                    );
824                }
825                let input = sanitized_request
826                    .as_ref()
827                    .and_then(|request| serde_json::to_value(request).ok());
828                let context = global_context();
829                match context.read() {
830                    Ok(state) => state.build_llm_start_event(&queued_handle, input, annotation),
831                    Err(_) => event,
832                }
833            })
834        }),
835        event_sanitizers,
836        &subscribers,
837        scope_stack,
838    );
839    Ok(handle)
840}
841
842#[derive(Clone, Copy)]
843struct LlmCallEndBehavior {
844    response_codec_errors_fatal: bool,
845    attach_estimated_cost: bool,
846}
847
848struct LlmEndPayload {
849    data: Option<Json>,
850    annotated_response: Option<Arc<AnnotatedLlmResponse>>,
851    decode_error: Option<FlowError>,
852}
853
854async fn build_llm_end_payload(
855    handle: &LlmHandle,
856    response: Json,
857    fallback_data: Option<Json>,
858    annotated_response: Option<Arc<AnnotatedLlmResponse>>,
859    response_codec: Option<Arc<dyn LlmResponseCodec>>,
860    entries: &[crate::api::registry::Guardrail<crate::api::runtime::LlmSanitizeResponseFn>],
861    behavior: LlmCallEndBehavior,
862) -> LlmEndPayload {
863    let response_was_null_without_fallback = response.is_null() && fallback_data.is_none();
864    let response = if response.is_null() {
865        fallback_data.unwrap_or(response)
866    } else {
867        response
868    };
869    let sanitized_response = NemoRelayContextState::llm_sanitize_response_snapshot_chain(
870        response.clone(),
871        LlmSanitizeResponseContext::for_response_codec(response_codec.clone()),
872        entries,
873    )
874    .await;
875    let response_changed = sanitized_response
876        .as_ref()
877        .is_some_and(|sanitized_response| sanitized_response != &response);
878    let data = match sanitized_response {
879        Some(response) if response_was_null_without_fallback && response.is_null() => None,
880        response => response,
881    };
882    let annotation_omitted = data.as_ref().is_none_or(Json::is_null);
883    let (mut annotated_response, decode_error) = if annotation_omitted {
884        (None, None)
885    } else {
886        resolve_llm_end_annotation(
887            (!response_changed).then_some(annotated_response).flatten(),
888            response_codec,
889            data.as_ref(),
890            &behavior,
891            &handle.name,
892        )
893    };
894    let pricing = crate::codec::response::active_pricing_resolver();
895    let summary = finalize_optimization_summary(
896        &handle.optimization_recorder,
897        annotated_response.as_mut(),
898        handle.model_name.as_deref(),
899        &pricing,
900    );
901    if !annotation_omitted
902        && annotated_response.is_none()
903        && let Some(summary) = summary
904    {
905        annotated_response = Some(AnnotatedLlmResponse {
906            optimization_summary: Some(summary),
907            ..AnnotatedLlmResponse::default()
908        });
909    }
910    LlmEndPayload {
911        data,
912        annotated_response: annotated_response.map(Arc::new),
913        decode_error,
914    }
915}
916
917/// Finish a manual LLM lifecycle span.
918///
919/// This emits an LLM-end event for a handle previously returned by
920/// [`llm_call`].
921///
922/// # Parameters
923/// - `handle`: LLM handle to close.
924/// - `response`: Raw provider response associated with the end event.
925/// - `data`: Optional application payload retained for compatibility. When the
926///   raw `response` is JSON null, this payload is sanitized in its place.
927/// - `metadata`: Optional JSON metadata recorded on the end event.
928/// - `annotated_response`: Optional normalized response annotation produced by
929///   a response codec. When omitted and `response_codec` is supplied, the
930///   annotation is decoded from the sanitized end-event payload.
931/// - `response_codec`: Optional response codec used to produce a normalized
932///   response annotation from the sanitized end-event payload.
933/// - `timestamp`: Optional timestamp recorded on the emitted end event. When
934///   `None`, the runtime uses the current UTC time, or one microsecond after
935///   the handle start time if the current time is not later.
936///
937/// # Returns
938/// A [`Result`] that is `Ok(())` when the end event has been queued for
939/// sanitization and publication.
940///
941/// # Errors
942/// Returns an error when the runtime owner check fails or internal state cannot
943/// be read safely. Dispatcher submission failures are logged because
944/// observability publication is best effort. Sanitizer errors discovered during
945/// queued publication are logged and fail closed by omitting the governed payload.
946/// Response-codec errors retain their documented fallback behavior.
947///
948/// # Notes
949/// Sanitize-response guardrails affect only the emitted end-event payload, not
950/// the caller-owned `response` value. If a sanitizer errors or panics, Relay
951/// omits the payload and response annotation and does not run remaining sanitizers.
952pub fn llm_call_end(params: LlmCallEndParams<'_>) -> Result<()> {
953    ensure_runtime_owner()?;
954    let scope_stack = params.handle.captured_scope_stack().clone();
955    let (entries, subscribers) = {
956        let scope_guard = scope_stack
957            .read()
958            .map_err(|error| FlowError::Internal(error.to_string()))?;
959        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
960            &registries.llm_sanitize_response_guardrails
961        });
962        let subscribers =
963            snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?;
964        let context = global_context();
965        let state = context
966            .read()
967            .map_err(|error| FlowError::Internal(error.to_string()))?;
968        (
969            state.llm_sanitize_response_entries(&scope_locals),
970            subscribers,
971        )
972    };
973    let response = params.response;
974    let fallback_data = params.data;
975    let handle = params.handle.clone();
976    let metadata = params.metadata;
977    let timestamp = params.timestamp;
978    let annotated_response = params.annotated_response;
979    let response_codec = params.response_codec;
980    handle.optimization_recorder.close_for_finalization(None);
981    enqueue_optimization_marks(&handle, &subscribers);
982    let event = {
983        let context = global_context();
984        let state = context
985            .read()
986            .map_err(|error| FlowError::Internal(error.to_string()))?;
987        state.build_llm_end_event(
988            EndLlmHandleParams::builder()
989                .handle(&handle)
990                .data(Json::Null)
991                .metadata_opt(metadata.clone())
992                .annotated_response_opt(annotated_response.clone())
993                .timestamp_opt(timestamp)
994                .build(),
995        )
996    };
997    let event_sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
998    dispatch_transformed_event(
999        event,
1000        Box::new(move |event| {
1001            Box::pin(async move {
1002                let payload = build_llm_end_payload(
1003                    &handle,
1004                    response,
1005                    fallback_data,
1006                    annotated_response,
1007                    response_codec,
1008                    &entries,
1009                    LlmCallEndBehavior {
1010                        response_codec_errors_fatal: false,
1011                        attach_estimated_cost: false,
1012                    },
1013                )
1014                .await;
1015                if let Some(error) = payload.decode_error {
1016                    log::error!(
1017                        target: "nemo_relay.runtime",
1018                        event = "manual_llm_response_codec_failed";
1019                        "Manual LLM response annotation failed during queued publication: {error}"
1020                    );
1021                }
1022                let context = global_context();
1023                let Ok(state) = context.read() else {
1024                    return event;
1025                };
1026                let end_metadata = metadata_with_otel_status(metadata, "OK", None);
1027                state.build_llm_end_event(
1028                    EndLlmHandleParams::builder()
1029                        .handle(&handle)
1030                        .data_opt(payload.data)
1031                        .metadata_opt(end_metadata)
1032                        .annotated_response_opt(payload.annotated_response)
1033                        .timestamp_opt(timestamp)
1034                        .build(),
1035                )
1036            })
1037        }),
1038        event_sanitizers,
1039        &subscribers,
1040        scope_stack,
1041    );
1042    Ok(())
1043}
1044
1045async fn llm_call_end_with_behavior(
1046    params: LlmCallEndParams<'_>,
1047    behavior: LlmCallEndBehavior,
1048    lifecycle_subscribers: Option<&[EventSubscriberFn]>,
1049) -> Result<()> {
1050    let LlmCallEndParams {
1051        handle,
1052        response,
1053        data,
1054        metadata,
1055        annotated_response,
1056        response_codec,
1057        timestamp,
1058    } = params;
1059    ensure_runtime_owner()?;
1060    let (entries, subscribers) = {
1061        let scope_stack = handle.captured_scope_stack();
1062        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1063        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1064            &registries.llm_sanitize_response_guardrails
1065        });
1066        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
1067        let subscribers = match lifecycle_subscribers {
1068            Some(subscribers) => subscribers.to_vec(),
1069            None => snapshot_event_subscribers(scope_subscribers)?,
1070        };
1071        let context = global_context();
1072        let state = context
1073            .read()
1074            .map_err(|error| FlowError::Internal(error.to_string()))?;
1075        let entries = state.llm_sanitize_response_entries(&scope_locals);
1076        (entries, subscribers)
1077    };
1078    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;
1090    let event = {
1091        let context = global_context();
1092        let state = context
1093            .read()
1094            .map_err(|error| FlowError::Internal(error.to_string()))?;
1095        let end_metadata = metadata_with_otel_status(metadata, "OK", None);
1096        state.build_llm_end_event(
1097            EndLlmHandleParams::builder()
1098                .handle(handle)
1099                .data_opt(payload.data)
1100                .metadata_opt(end_metadata)
1101                .annotated_response_opt(payload.annotated_response)
1102                .timestamp_opt(timestamp)
1103                .build(),
1104        )
1105    };
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    }
1114}
1115
1116#[cfg(test)]
1117fn sanitize_context_for_request_codec(codec: Option<&dyn LlmCodec>) -> LlmSanitizeRequestContext {
1118    LlmSanitizeRequestContext::with_identity(
1119        codec.map_or(LlmCodecIdentity::None, LlmCodec::codec_identity),
1120    )
1121}
1122
1123#[cfg(test)]
1124pub(crate) fn sanitize_context_for_response_codec(
1125    codec: Option<&dyn LlmResponseCodec>,
1126) -> LlmSanitizeResponseContext {
1127    LlmSanitizeResponseContext::with_identity(
1128        codec.map_or(LlmCodecIdentity::None, LlmResponseCodec::codec_identity),
1129    )
1130}
1131
1132fn resolve_llm_end_annotation(
1133    annotated_response: Option<Arc<AnnotatedLlmResponse>>,
1134    response_codec: Option<Arc<dyn LlmResponseCodec>>,
1135    data: Option<&Json>,
1136    behavior: &LlmCallEndBehavior,
1137    provider_name: &str,
1138) -> (Option<AnnotatedLlmResponse>, Option<FlowError>) {
1139    if let Some(annotated_response) = annotated_response {
1140        return (Some((*annotated_response).clone()), None);
1141    }
1142    let (Some(codec), Some(response)) = (response_codec, data) else {
1143        return (None, None);
1144    };
1145    match codec.decode_response(response) {
1146        Ok(mut decoded) => {
1147            if behavior.attach_estimated_cost {
1148                attach_estimated_cost_for_provider(&mut decoded, Some(provider_name));
1149            }
1150            (Some(decoded), None)
1151        }
1152        Err(error) => (None, Some(error)),
1153    }
1154}
1155
1156async fn emit_llm_end_without_output(
1157    handle: &LlmHandle,
1158    metadata: Option<Json>,
1159    response_codec: Option<Arc<dyn LlmResponseCodec>>,
1160    lifecycle_subscribers: Option<&[EventSubscriberFn]>,
1161) -> Result<()> {
1162    ensure_runtime_owner()?;
1163    let (entries, subscribers) = {
1164        let scope_stack = handle.captured_scope_stack();
1165        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1166        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1167            &registries.llm_sanitize_response_guardrails
1168        });
1169        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
1170        let subscribers = match lifecycle_subscribers {
1171            Some(subscribers) => subscribers.to_vec(),
1172            None => snapshot_event_subscribers(scope_subscribers)?,
1173        };
1174        let context = global_context();
1175        let state = context
1176            .read()
1177            .map_err(|error| FlowError::Internal(error.to_string()))?;
1178        let entries = state.llm_sanitize_response_entries(&scope_locals);
1179        (entries, subscribers)
1180    };
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);
1194    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        });
1213    let event = {
1214        let context = global_context();
1215        let state = context
1216            .read()
1217            .map_err(|error| FlowError::Internal(error.to_string()))?;
1218        state.end_llm_handle(handle, data, metadata, annotated_response)
1219    };
1220    queue_sanitized_event_with_scope_stack(event, &subscribers, handle.captured_scope_stack());
1221    Ok(())
1222}
1223
1224struct ManagedLlmCompletion {
1225    handle: Option<LlmHandle>,
1226    metadata: Option<Json>,
1227    response_codec: Option<Arc<dyn LlmResponseCodec>>,
1228    subscribers: Vec<EventSubscriberFn>,
1229    pending_publication: Option<PendingPublication>,
1230}
1231
1232impl ManagedLlmCompletion {
1233    fn new(
1234        handle: &LlmHandle,
1235        metadata: Option<Json>,
1236        response_codec: Option<Arc<dyn LlmResponseCodec>>,
1237        subscribers: &[EventSubscriberFn],
1238    ) -> Self {
1239        Self {
1240            handle: Some(handle.clone()),
1241            metadata,
1242            response_codec,
1243            subscribers: subscribers.to_vec(),
1244            pending_publication: (!subscribers.is_empty())
1245                .then(register_pending_publication)
1246                .flatten(),
1247        }
1248    }
1249
1250    fn disarm(&mut self) {
1251        self.handle = None;
1252        drop(self.pending_publication.take());
1253    }
1254}
1255
1256impl Drop for ManagedLlmCompletion {
1257    fn drop(&mut self) {
1258        let pending_publication = self.pending_publication.take();
1259        let Some(handle) = self.handle.take() else {
1260            return;
1261        };
1262        let metadata = metadata_with_otel_status(
1263            self.metadata.take(),
1264            "ERROR",
1265            Some("LLM execution cancelled".into()),
1266        );
1267        let scope_stack = handle.captured_scope_stack().clone();
1268        let entries = match scope_stack.read() {
1269            Ok(scope_guard) => {
1270                let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1271                    &registries.llm_sanitize_response_guardrails
1272                });
1273                global_context()
1274                    .read()
1275                    .map(|state| state.llm_sanitize_response_entries(&scope_locals))
1276                    .unwrap_or_default()
1277            }
1278            Err(_) => Vec::new(),
1279        };
1280        handle
1281            .optimization_recorder
1282            .close_for_finalization(Some("execution_cancelled"));
1283        enqueue_optimization_marks(&handle, &self.subscribers);
1284        let event = global_context()
1285            .read()
1286            .ok()
1287            .map(|state| state.end_llm_handle(&handle, None, metadata.clone(), None));
1288        let Some(event) = event else {
1289            return;
1290        };
1291        let event_sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
1292        let response_codec = self.response_codec.take();
1293        let subscribers = std::mem::take(&mut self.subscribers);
1294        let fallback_data = handle.data.clone();
1295        dispatch_transformed_event(
1296            event,
1297            Box::new(move |event| {
1298                Box::pin(async move {
1299                    let Some(data) = fallback_data else {
1300                        return event;
1301                    };
1302                    let data = NemoRelayContextState::llm_sanitize_response_snapshot_chain(
1303                        data,
1304                        LlmSanitizeResponseContext::for_response_codec(response_codec),
1305                        &entries,
1306                    )
1307                    .await;
1308                    let annotation_omitted = data.as_ref().is_none_or(Json::is_null);
1309                    let annotated_response = (!annotation_omitted)
1310                        .then(|| {
1311                            let pricing = crate::codec::response::active_pricing_resolver();
1312                            finalize_optimization_summary(
1313                                &handle.optimization_recorder,
1314                                None,
1315                                handle.model_name.as_deref(),
1316                                &pricing,
1317                            )
1318                        })
1319                        .flatten()
1320                        .map(|summary| {
1321                            Arc::new(AnnotatedLlmResponse {
1322                                optimization_summary: Some(summary),
1323                                ..AnnotatedLlmResponse::default()
1324                            })
1325                        });
1326                    global_context()
1327                        .read()
1328                        .map(|state| {
1329                            state.end_llm_handle(&handle, data, metadata, annotated_response)
1330                        })
1331                        .unwrap_or(event)
1332                })
1333            }),
1334            event_sanitizers,
1335            &subscribers,
1336            scope_stack,
1337        );
1338        drop(pending_publication);
1339    }
1340}
1341
1342/// Execute an LLM call through the managed middleware pipeline.
1343///
1344/// This runs conditional-execution guardrails, request intercepts, and
1345/// sanitize-request guardrails, emits the LLM-start event, then runs execution
1346/// intercepts, the provider callback when it is not replaced, and
1347/// sanitize-response guardrails in the runtime-defined order.
1348///
1349/// # Parameters
1350/// - `name`: Logical provider or model family name recorded on emitted events.
1351/// - `request`: Raw [`LlmRequest`] passed into the managed pipeline.
1352/// - `func`: Provider callback or execution continuation.
1353/// - `parent`: Optional explicit parent scope for the emitted LLM span.
1354/// - `attributes`: LLM attribute bitflags applied to the managed span.
1355/// - `data`: Optional application payload stored on the managed LLM handle. It
1356///   may be used on failure end events that have no output payload.
1357/// - `metadata`: Optional JSON metadata recorded on emitted events.
1358/// - `model_name`: Optional normalized model name for observability output.
1359/// - `codec`: Optional request codec used to produce annotated request data for
1360///   intercepts and events.
1361/// - `response_codec`: Optional response codec used to attach annotated
1362///   response data to the end event.
1363///
1364/// # Returns
1365/// A [`Result`] containing the raw JSON response returned by the callback or
1366/// an execution intercept.
1367///
1368/// # Errors
1369/// Returns [`FlowError::GuardrailRejected`] when conditional-execution
1370/// guardrails block the call, or any error raised by request intercepts,
1371/// execution intercepts, codecs, or the callback itself.
1372///
1373/// # Notes
1374/// The LLM-start event is emitted before execution intercepts run. Before
1375/// sanitize-request guardrails run, the runtime removes standard credential
1376/// headers from the event-only request copy; the request passed to execution is
1377/// unchanged. When execution fails after that point, the runtime still emits an
1378/// LLM-end event without an output payload.
1379///
1380/// Response codecs enrich observability output only and do not change the
1381/// value returned to the caller.
1382pub async fn llm_call_execute(params: LlmCallExecuteParams) -> Result<Json> {
1383    let LlmCallExecuteParams {
1384        name,
1385        request,
1386        func,
1387        parent,
1388        attributes,
1389        data,
1390        metadata,
1391        model_name,
1392        codec,
1393        response_codec,
1394    } = params;
1395    ensure_runtime_owner()?;
1396    {
1397        let (entries, subscribers, parent_uuid, guardrail_metadata) = {
1398            let scope_stack = current_scope_stack();
1399            let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1400            let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1401                &registries.llm_conditional_execution_guardrails
1402            });
1403            let scope_subscribers = scope_guard.collect_scope_local_subscribers();
1404            let context = global_context();
1405            let state = context
1406                .read()
1407                .map_err(|error| FlowError::Internal(error.to_string()))?;
1408            let entries = state.llm_conditional_execution_entries(&scope_locals);
1409            let subscribers = state.collect_event_subscribers(&scope_subscribers);
1410            (
1411                entries,
1412                subscribers,
1413                resolve_parent_uuid(parent.as_ref()),
1414                metadata.clone(),
1415            )
1416        };
1417        if let Some(error) = NemoRelayContextState::llm_conditional_execution_snapshot_chain(
1418            &request,
1419            &entries,
1420            &subscribers,
1421            parent_uuid,
1422            guardrail_metadata,
1423        )
1424        .await?
1425        {
1426            let mut rejection_data = json!({});
1427            if let Some(object) = rejection_data.as_object_mut() {
1428                object.insert("rejected".into(), json!(true));
1429                object.insert("rejection_reason".into(), json!(&error));
1430            }
1431            let _ = event(
1432                EmitMarkEventParams::builder()
1433                    .name(&name)
1434                    .parent_opt(parent.as_ref())
1435                    .data(rejection_data)
1436                    .metadata_opt(metadata.clone())
1437                    .build(),
1438            );
1439            return Err(FlowError::GuardrailRejected(error));
1440        }
1441    }
1442
1443    let request_codec = codec.clone();
1444    let optimization_recorder = LlmOptimizationRecorder::default();
1445    let (intercepted_request, annotated_request, pending_marks, optimization_contributions) =
1446        scope_llm_optimization_recorder(optimization_recorder.clone(), async {
1447            run_request_intercepts_with_codec_and_recorder(
1448                &name,
1449                request,
1450                codec,
1451                &optimization_recorder,
1452            )
1453            .await
1454        })
1455        .await?;
1456
1457    let mut handle = create_llm_handle(
1458        CreateLlmHandleParams::builder()
1459            .name(name.as_str())
1460            .parent_uuid_opt(resolve_parent_uuid(parent.as_ref()))
1461            .attributes(attributes)
1462            .data_opt(data.clone())
1463            .metadata_opt(metadata.clone())
1464            .model_name_opt(model_name)
1465            .build(),
1466    )?;
1467    handle.optimization_recorder = optimization_recorder;
1468    let lifecycle_subscribers = {
1469        let scope_stack = handle.captured_scope_stack();
1470        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1471        snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?
1472    };
1473    emit_llm_start_with_subscribers(
1474        &handle,
1475        &intercepted_request,
1476        annotated_request.clone(),
1477        request_codec.clone(),
1478        &lifecycle_subscribers,
1479    )
1480    .await?;
1481    emit_pending_request_marks(&handle, pending_marks, &lifecycle_subscribers).await?;
1482    handle
1483        .optimization_recorder
1484        .record_all(optimization_contributions);
1485    emit_optimization_marks(&handle, &lifecycle_subscribers).await;
1486
1487    let mut completion = ManagedLlmCompletion::new(
1488        &handle,
1489        metadata.clone(),
1490        response_codec.clone(),
1491        &lifecycle_subscribers,
1492    );
1493    let execution_name = name.clone();
1494    let event_uuid = handle.uuid;
1495    let execution = with_active_event_uuid(
1496        event_uuid,
1497        scope_llm_optimization_recorder(handle.optimization_recorder.clone(), async move {
1498            let execution = {
1499                let scope_stack = current_scope_stack();
1500                let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1501                let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1502                    &registries.llm_execution_intercepts
1503                });
1504                let context = global_context();
1505                let state = context
1506                    .read()
1507                    .map_err(|error| FlowError::Internal(error.to_string()))?;
1508                state.llm_build_execution_chain(&execution_name, func, &scope_locals)
1509            };
1510            execution(intercepted_request).await
1511        }),
1512    )
1513    .await;
1514
1515    match execution {
1516        Ok(response) => {
1517            llm_call_end_with_behavior(
1518                LlmCallEndParams::builder()
1519                    .handle(&handle)
1520                    .response(response.clone())
1521                    .data_opt(data)
1522                    .metadata_opt(metadata)
1523                    .response_codec_opt(response_codec)
1524                    .build(),
1525                LlmCallEndBehavior {
1526                    response_codec_errors_fatal: false,
1527                    attach_estimated_cost: true,
1528                },
1529                Some(&lifecycle_subscribers),
1530            )
1531            .await?;
1532            completion.disarm();
1533            Ok(response)
1534        }
1535        Err(error) => {
1536            let end_metadata = metadata_with_otel_error(metadata, &error);
1537            let _ = emit_llm_end_without_output(
1538                &handle,
1539                end_metadata,
1540                response_codec,
1541                Some(&lifecycle_subscribers),
1542            )
1543            .await;
1544            completion.disarm();
1545            Err(error)
1546        }
1547    }
1548}
1549
1550/// Execute a streaming LLM call through the managed middleware pipeline.
1551///
1552/// This runs the same pre-execution middleware as [`llm_call_execute`], emits
1553/// the LLM-start event, and then wraps the provider stream so chunk callbacks
1554/// and finalization can emit a single LLM-end event when streaming completes.
1555///
1556/// # Parameters
1557/// - `name`: Logical provider or model family name recorded on emitted events.
1558/// - `request`: Raw [`LlmRequest`] passed into the managed pipeline.
1559/// - `func`: Streaming provider callback or execution continuation.
1560/// - `collector`: Per-chunk collector callback used to accumulate stream state.
1561/// - `finalizer`: Finalizer callback used to construct the completed response.
1562/// - `parent`: Optional explicit parent scope for the emitted LLM span.
1563/// - `attributes`: LLM attribute bitflags applied to the managed span.
1564/// - `data`: Optional application payload stored on the managed LLM handle. It
1565///   may be used on failure end events that have no output payload.
1566/// - `metadata`: Optional JSON metadata recorded on emitted events.
1567/// - `model_name`: Optional normalized model name for observability output.
1568/// - `codec`: Optional request codec used to produce annotated request data for
1569///   intercepts and events.
1570/// - `response_codec`: Optional response codec used to attach annotated
1571///   response data to the end event.
1572///
1573/// # Returns
1574/// A [`Result`] containing a boxed stream of JSON chunks.
1575///
1576/// # Errors
1577/// Returns [`FlowError::GuardrailRejected`] when conditional-execution
1578/// guardrails block the call, or any error raised by request intercepts,
1579/// execution intercepts, stream callbacks, codecs, or the provider callback.
1580///
1581/// # Notes
1582/// The LLM-start event is emitted before stream execution intercepts run.
1583/// Before sanitize-request guardrails run, the runtime removes standard
1584/// credential headers from the event-only request copy; the request passed to
1585/// stream execution is unchanged.
1586///
1587/// The returned stream emits chunk-level results while the runtime defers the
1588/// LLM-end event until the collector and finalizer complete.
1589pub async fn llm_stream_call_execute(params: LlmStreamCallExecuteParams) -> Result<LlmJsonStream> {
1590    let LlmStreamCallExecuteParams {
1591        name,
1592        request,
1593        func,
1594        collector,
1595        finalizer,
1596        parent,
1597        attributes,
1598        data,
1599        metadata,
1600        model_name,
1601        codec,
1602        response_codec,
1603    } = params;
1604    ensure_runtime_owner()?;
1605    {
1606        let (entries, subscribers, parent_uuid, guardrail_metadata) = {
1607            let scope_stack = current_scope_stack();
1608            let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1609            let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1610                &registries.llm_conditional_execution_guardrails
1611            });
1612            let scope_subscribers = scope_guard.collect_scope_local_subscribers();
1613            let context = global_context();
1614            let state = context
1615                .read()
1616                .map_err(|error| FlowError::Internal(error.to_string()))?;
1617            let entries = state.llm_conditional_execution_entries(&scope_locals);
1618            let subscribers = state.collect_event_subscribers(&scope_subscribers);
1619            (
1620                entries,
1621                subscribers,
1622                resolve_parent_uuid(parent.as_ref()),
1623                metadata.clone(),
1624            )
1625        };
1626        if let Some(error) = NemoRelayContextState::llm_conditional_execution_snapshot_chain(
1627            &request,
1628            &entries,
1629            &subscribers,
1630            parent_uuid,
1631            guardrail_metadata,
1632        )
1633        .await?
1634        {
1635            let mut rejection_data = json!({});
1636            if let Some(object) = rejection_data.as_object_mut() {
1637                object.insert("rejected".into(), json!(true));
1638                object.insert("rejection_reason".into(), json!(&error));
1639            }
1640            let _ = event(
1641                EmitMarkEventParams::builder()
1642                    .name(&name)
1643                    .parent_opt(parent.as_ref())
1644                    .data(rejection_data)
1645                    .metadata_opt(metadata.clone())
1646                    .build(),
1647            );
1648            return Err(FlowError::GuardrailRejected(error));
1649        }
1650    }
1651
1652    let request_codec = codec.clone();
1653    let optimization_recorder = LlmOptimizationRecorder::default();
1654    let (intercepted_request, annotated_request, pending_marks, optimization_contributions) =
1655        scope_llm_optimization_recorder(optimization_recorder.clone(), async {
1656            run_request_intercepts_with_codec_and_recorder(
1657                &name,
1658                request,
1659                codec,
1660                &optimization_recorder,
1661            )
1662            .await
1663        })
1664        .await?;
1665
1666    let mut handle = create_llm_handle(
1667        CreateLlmHandleParams::builder()
1668            .name(name.as_str())
1669            .parent_uuid_opt(resolve_parent_uuid(parent.as_ref()))
1670            .attributes(attributes)
1671            .data_opt(data.clone())
1672            .metadata_opt(metadata.clone())
1673            .model_name_opt(model_name)
1674            .build(),
1675    )?;
1676    handle.optimization_recorder = optimization_recorder;
1677    let lifecycle_subscribers = {
1678        let scope_stack = handle.captured_scope_stack();
1679        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1680        snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?
1681    };
1682    emit_llm_start_with_subscribers(
1683        &handle,
1684        &intercepted_request,
1685        annotated_request,
1686        request_codec.clone(),
1687        &lifecycle_subscribers,
1688    )
1689    .await?;
1690    emit_pending_request_marks(&handle, pending_marks, &lifecycle_subscribers).await?;
1691    handle
1692        .optimization_recorder
1693        .record_all(optimization_contributions);
1694    emit_optimization_marks(&handle, &lifecycle_subscribers).await;
1695
1696    let mut completion = ManagedLlmCompletion::new(
1697        &handle,
1698        metadata.clone(),
1699        response_codec.clone(),
1700        &lifecycle_subscribers,
1701    );
1702    let execution_name = name.clone();
1703    let event_uuid = handle.uuid;
1704    let execution = with_active_event_uuid(
1705        event_uuid,
1706        scope_llm_optimization_recorder(handle.optimization_recorder.clone(), async move {
1707            let execution = {
1708                let scope_stack = current_scope_stack();
1709                let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1710                let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1711                    &registries.llm_stream_execution_intercepts
1712                });
1713                let context = global_context();
1714                let state = context
1715                    .read()
1716                    .map_err(|error| FlowError::Internal(error.to_string()))?;
1717                state.llm_stream_build_execution_chain(&execution_name, func, &scope_locals)
1718            };
1719            let execution_context = MiddlewareContinuationContext::capture();
1720            execution(intercepted_request)
1721                .await
1722                .map(|stream| contextualize_stream(stream, execution_context))
1723        }),
1724    )
1725    .await;
1726
1727    match execution {
1728        Ok(raw_stream) => {
1729            let wrapper = LlmStreamWrapper::new_managed(
1730                raw_stream,
1731                handle,
1732                collector,
1733                finalizer,
1734                metadata,
1735                response_codec,
1736                lifecycle_subscribers,
1737            );
1738            completion.disarm();
1739            Ok(LlmJsonStream::from_closeable(wrapper))
1740        }
1741        Err(error) => {
1742            let end_metadata = metadata_with_otel_error(metadata, &error);
1743            let _ = emit_llm_end_without_output(
1744                &handle,
1745                end_metadata,
1746                response_codec,
1747                Some(&lifecycle_subscribers),
1748            )
1749            .await;
1750            completion.disarm();
1751            Err(error)
1752        }
1753    }
1754}
1755
1756/// Run only the LLM request-intercept chain.
1757///
1758/// This applies the currently active global and scope-local request intercepts
1759/// without emitting lifecycle events or invoking provider execution.
1760///
1761/// # Parameters
1762/// - `name`: Logical provider or model family name used when resolving the
1763///   intercept chain.
1764/// - `request`: Raw [`LlmRequest`] to transform.
1765///
1766/// # Returns
1767/// A [`Result`] containing the transformed [`LlmRequest`].
1768///
1769/// # Errors
1770/// Returns any error raised by the request-intercept chain.
1771///
1772/// # Notes
1773/// Conditional guardrails, codecs, and execution intercepts are not run by
1774/// this helper.
1775/// Run the LLM request-intercept chain and return its complete outcome.
1776///
1777/// This helper does not emit the returned marks because it does not own an LLM
1778/// lifecycle. Callers must attach them to the lifecycle they own.
1779pub async fn llm_request_intercepts(
1780    name: &str,
1781    request: LlmRequest,
1782) -> Result<LlmRequestInterceptOutcome> {
1783    ensure_runtime_owner()?;
1784    let entries = {
1785        let scope_stack = current_scope_stack();
1786        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1787        let scope_locals = scope_guard
1788            .collect_scope_local_registries(|registries| &registries.llm_request_intercepts);
1789        let context = global_context();
1790        let state = context
1791            .read()
1792            .map_err(|error| FlowError::Internal(error.to_string()))?;
1793        state.llm_request_intercept_entries(&scope_locals)
1794    };
1795    let mut outcome = NemoRelayContextState::llm_request_intercepts_snapshot_chain(
1796        name, request, None, &entries, false,
1797    )
1798    .await?;
1799    inject_dynamo_session_ids(&mut outcome.request);
1800    Ok(outcome)
1801}
1802
1803/// Run only the LLM conditional-execution guardrail chain.
1804///
1805/// This evaluates whether an LLM call should be allowed to proceed without
1806/// invoking request intercepts or execution. Each evaluated guardrail emits an
1807/// automatic guardrail scope start/end pair for observability.
1808///
1809/// # Parameters
1810/// - `request`: Raw [`LlmRequest`] to validate.
1811///
1812/// # Returns
1813/// A [`Result`] that is `Ok(())` when all guardrails allow execution.
1814///
1815/// # Errors
1816/// Returns [`FlowError::GuardrailRejected`] when a guardrail blocks execution,
1817/// or any error raised by the guardrail chain itself.
1818///
1819/// # Notes
1820/// This helper is useful for preflight checks when the caller needs the
1821/// rejection result without starting an LLM span. Guardrail scopes are still
1822/// emitted for the conditional checks themselves.
1823pub async fn llm_conditional_execution(request: &LlmRequest) -> Result<()> {
1824    ensure_runtime_owner()?;
1825    let (entries, subscribers, parent_uuid) = {
1826        let scope_stack = current_scope_stack();
1827        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
1828        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
1829            &registries.llm_conditional_execution_guardrails
1830        });
1831        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
1832        let context = global_context();
1833        let state = context
1834            .read()
1835            .map_err(|error| FlowError::Internal(error.to_string()))?;
1836        let entries = state.llm_conditional_execution_entries(&scope_locals);
1837        let subscribers = state.collect_event_subscribers(&scope_subscribers);
1838        (entries, subscribers, resolve_parent_uuid(None))
1839    };
1840    if let Some(error) = NemoRelayContextState::llm_conditional_execution_snapshot_chain(
1841        request,
1842        &entries,
1843        &subscribers,
1844        parent_uuid,
1845        None,
1846    )
1847    .await?
1848    {
1849        return Err(FlowError::GuardrailRejected(error));
1850    }
1851    Ok(())
1852}
1853
1854#[cfg(test)]
1855#[path = "../../tests/unit/llm_api_tests.rs"]
1856mod tests;