Skip to main content

nemo_relay/api/runtime/
callbacks.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Callback type aliases used by the runtime middleware pipeline.
5//!
6//! The public middleware registration APIs accept callback closures with the
7//! signatures defined in this module. These aliases centralize those signatures
8//! so the runtime can compose tool and LLM middleware consistently across
9//! bindings.
10
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::Arc;
14use std::task::{Context, Poll};
15
16use tokio_stream::Stream;
17
18use crate::api::event::{Event, EventSanitizeFields};
19use crate::api::llm::{LlmRequest, LlmRequestInterceptOutcome};
20use crate::api::tool::ToolExecutionInterceptOutcome;
21use crate::codec::request::AnnotatedLlmRequest;
22use crate::codec::traits::{LlmCodec, LlmResponseCodec};
23use crate::error::Result;
24use crate::json::Json;
25
26/// Sanitize mutable observability fields on a fully constructed event.
27///
28/// The callback receives the current event as immutable context and the fields
29/// it may replace. Later callbacks observe fields returned by earlier entries.
30pub type EventSanitizeFn = Arc<
31    dyn Fn(
32            Arc<Event>,
33            EventSanitizeFields,
34        ) -> Pin<Box<dyn Future<Output = Result<EventSanitizeFields>> + Send>>
35        + Send
36        + Sync,
37>;
38
39/// Sanitize a tool request payload before the runtime records it.
40///
41/// Tool sanitize callbacks are used only for observability payloads. They can
42/// rewrite the JSON arguments recorded on tool-start events without changing
43/// the caller-owned request that is passed to the tool implementation.
44///
45/// # Parameters
46/// - First argument: Tool name associated with the request payload.
47/// - Second argument: JSON payload to sanitize for observability.
48///
49/// # Returns
50/// Sanitized JSON payload for the emitted event.
51pub type ToolSanitizeFn =
52    Arc<dyn Fn(String, Json) -> Pin<Box<dyn Future<Output = Result<Json>> + Send>> + Send + Sync>;
53/// Decide whether a tool call is allowed to continue.
54///
55/// The callback receives the tool name and the current argument payload. It can
56/// return `Ok(None)` to allow execution, `Ok(Some(reason))` to reject the call
57/// with a guardrail message, or an error to abort evaluation entirely.
58///
59/// This alias is [`Arc`]-backed so the runtime can clone conditional
60/// guardrails into an evaluation snapshot and invoke them after registry locks
61/// are released.
62///
63/// # Parameters
64/// - First argument: Tool name being evaluated.
65/// - Second argument: Current tool argument payload.
66///
67/// # Returns
68/// A [`Result`] containing `Ok(None)` when execution is allowed or
69/// `Ok(Some(reason))` when the guardrail rejects the call.
70///
71/// # Errors
72/// The callback can return any [`FlowError`](crate::error::FlowError) to abort
73/// guardrail evaluation.
74pub type ToolConditionalFn = Arc<
75    dyn Fn(String, Json) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send>>
76        + Send
77        + Sync,
78>;
79/// Rewrite tool arguments before execution.
80///
81/// Tool request intercepts run in priority order and can transform the JSON
82/// payload that is eventually passed into the tool execution callback.
83///
84/// # Parameters
85/// - First argument: Tool name associated with the request.
86/// - Second argument: JSON argument payload to transform.
87///
88/// # Returns
89/// A [`Result`] containing the transformed JSON argument payload.
90///
91/// # Errors
92/// The callback can return any [`FlowError`](crate::error::FlowError) to abort
93/// the request-intercept chain.
94pub type ToolInterceptFn =
95    Arc<dyn Fn(String, Json) -> Pin<Box<dyn Future<Output = Result<Json>> + Send>> + Send + Sync>;
96/// Continuation type invoked by tool execution intercepts.
97///
98/// Execution intercepts receive this callable as their `next` continuation and
99/// can call it with modified arguments, wrap it, or skip it entirely.
100///
101/// # Parameters
102/// - First argument: JSON argument payload to pass to the remaining execution
103///   chain.
104///
105/// # Returns
106/// A future resolving to the downstream tool result JSON. Pending marks from
107/// downstream intercepts are retained by the runtime and are not exposed
108/// through this continuation.
109///
110/// # Errors
111/// The future resolves to an error when the remaining execution chain fails.
112///
113/// # Lifetime
114/// This continuation can be called repeatedly or concurrently while its
115/// execution-intercept callback is still running. Each invocation receives an
116/// isolated snapshot of the scopes visible when `next` is called. Calls that
117/// remain unfinished or begin after the interceptor settles are rejected.
118pub type ToolExecutionNextFn =
119    Arc<dyn Fn(Json) -> Pin<Box<dyn Future<Output = Result<Json>> + Send>> + Send + Sync>;
120/// Wrap or replace tool execution.
121///
122/// A tool execution intercept receives the tool name, the current argument
123/// payload, and the continuation representing the rest of the chain.
124///
125/// # Parameters
126/// - First argument: Tool name associated with the execution.
127/// - Second argument: Current JSON argument payload.
128/// - Third argument: Continuation for the remaining execution chain.
129///
130/// # Returns
131/// A future resolving to the canonical tool execution outcome, containing the
132/// tool result and any pending lifecycle marks produced by this intercept.
133///
134/// # Errors
135/// The future resolves to an error when the intercept or remaining execution
136/// chain fails.
137pub type ToolExecutionFn = Arc<
138    dyn Fn(
139            &str,
140            Json,
141            ToolExecutionNextFn,
142        ) -> Pin<Box<dyn Future<Output = Result<ToolExecutionInterceptOutcome>> + Send>>
143        + Send
144        + Sync,
145>;
146
147/// Internal continuation carrying both a tool result and accumulated marks.
148pub(crate) type ToolExecutionOutcomeNextFn = Arc<
149    dyn Fn(Json) -> Pin<Box<dyn Future<Output = Result<ToolExecutionInterceptOutcome>> + Send>>
150        + Send
151        + Sync,
152>;
153
154/// Relay's built-in LLM codec identities.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum BuiltinLlmCodec {
157    /// OpenAI Chat Completions request and response payloads.
158    OpenAiChat,
159    /// OpenAI Responses request and response payloads.
160    OpenAiResponses,
161    /// Anthropic Messages request and response payloads.
162    AnthropicMessages,
163}
164
165impl BuiltinLlmCodec {
166    /// Stable identifier used in configuration and language bindings.
167    #[must_use]
168    pub const fn id(self) -> &'static str {
169        match self {
170            Self::OpenAiChat => "openai_chat",
171            Self::OpenAiResponses => "openai_responses",
172            Self::AnthropicMessages => "anthropic_messages",
173        }
174    }
175}
176
177/// Per-call LLM codec identity supplied to sanitize guardrails.
178#[derive(Debug, Clone, PartialEq, Eq, Default)]
179pub enum LlmCodecIdentity {
180    /// No codec was active for this payload direction.
181    #[default]
182    None,
183    /// A Relay built-in codec was active.
184    BuiltIn(BuiltinLlmCodec),
185    /// A runtime-registered codec was active, identified by its stable ID.
186    Runtime(String),
187    /// A codec was active but does not expose a registered identity.
188    Opaque,
189}
190
191/// Per-call codec context for LLM request sanitize guardrails.
192///
193/// The context distinguishes no codec, Relay built-ins, runtime-registered
194/// codecs, and active codecs with no stable identity.
195#[derive(Clone, Default)]
196pub struct LlmSanitizeRequestContext {
197    /// Identity of the codec active for this payload direction.
198    codec: LlmCodecIdentity,
199    request_codec: Option<Arc<dyn LlmCodec>>,
200}
201
202impl std::fmt::Debug for LlmSanitizeRequestContext {
203    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        formatter
205            .debug_struct("LlmSanitizeRequestContext")
206            .field("codec", &self.codec)
207            .finish_non_exhaustive()
208    }
209}
210
211impl LlmSanitizeRequestContext {
212    /// Construct a context that carries only a codec identity.
213    ///
214    /// Identity-only contexts do not carry a codec handle, so
215    /// [`Self::resolve_codec`] returns `None` even when the identity describes
216    /// an active codec.
217    #[must_use]
218    pub fn with_identity(codec: LlmCodecIdentity) -> Self {
219        Self {
220            codec,
221            ..Self::default()
222        }
223    }
224
225    /// Construct request-sanitizer context from the active request codec.
226    #[must_use]
227    pub fn for_request_codec(codec: Option<Arc<dyn LlmCodec>>) -> Self {
228        let identity = codec
229            .as_deref()
230            .map_or(LlmCodecIdentity::None, LlmCodec::codec_identity);
231        Self {
232            codec: identity,
233            request_codec: codec,
234        }
235    }
236
237    /// Return the identity of the codec active for this payload direction.
238    #[must_use]
239    pub fn codec(&self) -> &LlmCodecIdentity {
240        &self.codec
241    }
242
243    /// Resolve the active request codec.
244    ///
245    /// Returns `None` for contexts constructed with [`Self::with_identity`].
246    #[must_use]
247    pub fn resolve_codec(&self) -> Option<Arc<dyn LlmCodec>> {
248        self.request_codec.clone()
249    }
250}
251
252/// Per-call codec context for LLM response sanitize guardrails.
253///
254/// The context distinguishes no codec, Relay built-ins, runtime-registered
255/// codecs, and active codecs with no stable identity.
256#[derive(Clone, Default)]
257pub struct LlmSanitizeResponseContext {
258    /// Identity of the codec active for this payload direction.
259    codec: LlmCodecIdentity,
260    response_codec: Option<Arc<dyn LlmResponseCodec>>,
261}
262
263impl std::fmt::Debug for LlmSanitizeResponseContext {
264    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        formatter
266            .debug_struct("LlmSanitizeResponseContext")
267            .field("codec", &self.codec)
268            .finish_non_exhaustive()
269    }
270}
271
272impl LlmSanitizeResponseContext {
273    /// Construct a context that carries only a codec identity.
274    ///
275    /// Identity-only contexts do not carry a codec handle, so
276    /// [`Self::resolve_codec`] returns `None` even when the identity describes
277    /// an active codec.
278    #[must_use]
279    pub fn with_identity(codec: LlmCodecIdentity) -> Self {
280        Self {
281            codec,
282            ..Self::default()
283        }
284    }
285
286    /// Construct response-sanitizer context from the active response codec.
287    #[must_use]
288    pub fn for_response_codec(codec: Option<Arc<dyn LlmResponseCodec>>) -> Self {
289        let identity = codec
290            .as_deref()
291            .map_or(LlmCodecIdentity::None, LlmResponseCodec::codec_identity);
292        Self {
293            codec: identity,
294            response_codec: codec,
295        }
296    }
297
298    /// Return the identity of the codec active for this payload direction.
299    #[must_use]
300    pub fn codec(&self) -> &LlmCodecIdentity {
301        &self.codec
302    }
303
304    /// Resolve the active response codec.
305    ///
306    /// Returns `None` for contexts constructed with [`Self::with_identity`].
307    #[must_use]
308    pub fn resolve_codec(&self) -> Option<Arc<dyn LlmResponseCodec>> {
309        self.response_codec.clone()
310    }
311}
312
313/// Sanitize an LLM request before the runtime records it.
314///
315/// LLM request sanitizers affect the serialized request payload emitted on
316/// start events. They do not mutate the caller-owned [`LlmRequest`] unless a
317/// separate request intercept does so.
318///
319/// # Parameters
320/// - First argument: LLM request payload to sanitize for observability.
321/// - Second argument: Per-call request codec identity and capability.
322///
323/// # Returns
324/// `Some` contains the sanitized request for the emitted event. `None` omits
325/// both the raw request payload and its annotation from that event.
326///
327/// The context is always supplied and distinguishes no codec, built-in codecs,
328/// runtime-registered codecs, and opaque active codecs.
329pub type LlmSanitizeRequestFn = Arc<
330    dyn Fn(
331            LlmRequest,
332            LlmSanitizeRequestContext,
333        ) -> Pin<Box<dyn Future<Output = Result<Option<LlmRequest>>> + Send>>
334        + Send
335        + Sync,
336>;
337/// Sanitize an LLM response before the runtime records it.
338///
339/// These callbacks rewrite the JSON response payload captured on LLM-end
340/// events, which is useful for redaction or payload normalization.
341///
342/// # Parameters
343/// - First argument: JSON response payload to sanitize for observability.
344/// - Second argument: Per-call response codec identity and capability.
345///
346/// # Returns
347/// `Some` contains the sanitized response for the emitted event. `None` omits
348/// both the raw response payload and its annotation from that event.
349///
350/// The context is always supplied and distinguishes no codec, built-in codecs,
351/// runtime-registered codecs, and opaque active codecs.
352pub type LlmSanitizeResponseFn = Arc<
353    dyn Fn(
354            Json,
355            LlmSanitizeResponseContext,
356        ) -> Pin<Box<dyn Future<Output = Result<Option<Json>>> + Send>>
357        + Send
358        + Sync,
359>;
360/// Decide whether an LLM call is allowed to continue.
361///
362/// The callback receives the current [`LlmRequest`] and can allow execution,
363/// reject it with a guardrail reason, or return an error.
364///
365/// This alias is [`Arc`]-backed so the runtime can clone conditional
366/// guardrails into an evaluation snapshot and invoke them after registry locks
367/// are released.
368///
369/// # Parameters
370/// - First argument: Current [`LlmRequest`] being evaluated.
371///
372/// # Returns
373/// A [`Result`] containing `Ok(None)` when execution is allowed or
374/// `Ok(Some(reason))` when the guardrail rejects the call.
375///
376/// # Errors
377/// The callback can return any [`FlowError`](crate::error::FlowError) to abort
378/// guardrail evaluation.
379pub type LlmConditionalFn = Arc<
380    dyn Fn(LlmRequest) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send>>
381        + Send
382        + Sync,
383>;
384/// Rewrite or annotate an LLM request before execution.
385///
386/// Request intercepts can transform the wire request, attach or replace a
387/// normalized [`AnnotatedLlmRequest`], or both.
388///
389/// # Parameters
390/// - First argument: Logical provider or model family name.
391/// - Second argument: LLM request to transform.
392/// - Third argument: Optional normalized request annotation to carry forward.
393///
394/// # Returns
395/// A [`Result`] containing the canonical request-intercept outcome.
396/// Without a request codec, the returned request is authoritative. With a
397/// request codec, its headers remain writable while its content must remain
398/// unchanged; provider-body edits must be returned through the required
399/// annotation.
400///
401/// # Errors
402/// The callback can return any [`FlowError`](crate::error::FlowError) to abort
403/// the request-intercept chain.
404pub type LlmRequestInterceptFn = Arc<
405    dyn Fn(
406            String,
407            LlmRequest,
408            Option<AnnotatedLlmRequest>,
409        ) -> Pin<Box<dyn Future<Output = Result<LlmRequestInterceptOutcome>> + Send>>
410        + Send
411        + Sync,
412>;
413/// Continuation type invoked by non-streaming LLM execution intercepts.
414///
415/// Execution intercepts use this callable to continue the non-streaming LLM
416/// pipeline after applying their own logic.
417///
418/// # Parameters
419/// - First argument: LLM request to pass to the remaining execution chain.
420///
421/// # Returns
422/// A future resolving to the provider response JSON.
423///
424/// # Errors
425/// The future resolves to an error when the remaining execution chain fails.
426///
427/// # Lifetime
428/// This continuation can be called repeatedly or concurrently while its
429/// execution-intercept callback is still running. Each invocation receives an
430/// isolated snapshot of the scopes visible when `next` is called. Calls that
431/// remain unfinished or begin after the interceptor settles are rejected.
432pub type LlmExecutionNextFn =
433    Arc<dyn Fn(LlmRequest) -> Pin<Box<dyn Future<Output = Result<Json>> + Send>> + Send + Sync>;
434/// Wrap or replace non-streaming LLM execution.
435///
436/// A non-streaming execution intercept receives the logical provider name, the
437/// current request, and the continuation representing the rest of the chain.
438///
439/// # Parameters
440/// - First argument: Logical provider or model family name.
441/// - Second argument: Current LLM request.
442/// - Third argument: Continuation for the remaining execution chain.
443///
444/// # Returns
445/// A future resolving to the provider response JSON.
446///
447/// # Errors
448/// The future resolves to an error when the intercept or remaining execution
449/// chain fails.
450pub type LlmExecutionFn = Arc<
451    dyn Fn(
452            &str,
453            LlmRequest,
454            LlmExecutionNextFn,
455        ) -> Pin<Box<dyn Future<Output = Result<Json>> + Send>>
456        + Send
457        + Sync,
458>;
459/// Stream of JSON chunks produced by the managed streaming LLM pipeline.
460///
461/// In addition to ordinary stream polling, managed streams provide an explicit
462/// asynchronous close operation. A successful close means the producer has
463/// released its resources; subsequent polls return no more chunks.
464pub struct LlmJsonStream {
465    inner: Pin<Box<dyn LlmStreamInner>>,
466}
467
468impl LlmJsonStream {
469    /// Wrap a stream whose producer has no asynchronous teardown work.
470    pub fn new<S>(stream: S) -> Self
471    where
472        S: Stream<Item = Result<Json>> + Send + 'static,
473    {
474        Self {
475            inner: Box::pin(DefaultLlmStream {
476                stream: Some(Box::pin(stream)),
477            }),
478        }
479    }
480
481    /// Wrap a stream that implements explicit asynchronous teardown.
482    pub fn from_closeable<S>(stream: S) -> Self
483    where
484        S: LlmStreamInner + 'static,
485    {
486        Self {
487            inner: Box::pin(stream),
488        }
489    }
490
491    /// Stop the producer and wait for its cleanup to complete.
492    pub async fn close(&mut self) -> Result<()> {
493        self.inner.as_mut().close().await
494    }
495
496    pub(crate) fn terminalize(&mut self) {
497        self.inner.as_mut().terminalize();
498    }
499}
500
501impl Stream for LlmJsonStream {
502    type Item = Result<Json>;
503
504    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
505        self.get_mut().inner.as_mut().poll_next(cx)
506    }
507}
508
509/// Internal close-aware stream implementation.
510pub trait LlmStreamInner: Stream<Item = Result<Json>> + Send {
511    /// Release lifecycle guards once the consumer-visible stream has ended
512    /// while retaining the producer for a later explicit close.
513    fn terminalize(self: Pin<&mut Self>) {}
514
515    /// Stop the producer and wait for cleanup. Implementations must be idempotent.
516    fn close(self: Pin<&mut Self>) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
517}
518
519struct DefaultLlmStream<S> {
520    stream: Option<Pin<Box<S>>>,
521}
522
523impl<S> Stream for DefaultLlmStream<S>
524where
525    S: Stream<Item = Result<Json>> + Send,
526{
527    type Item = Result<Json>;
528
529    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
530        let this = self.get_mut();
531        match this.stream.as_mut() {
532            Some(stream) => stream.as_mut().poll_next(cx),
533            None => Poll::Ready(None),
534        }
535    }
536}
537
538impl<S> LlmStreamInner for DefaultLlmStream<S>
539where
540    S: Stream<Item = Result<Json>> + Send,
541{
542    fn close(self: Pin<&mut Self>) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
543        self.get_mut().stream.take();
544        Box::pin(async { Ok(()) })
545    }
546}
547/// Per-chunk collector used by the streaming LLM runtime.
548///
549/// # Parameters
550/// - First argument: One JSON chunk emitted by the provider stream.
551///
552/// # Returns
553/// A [`Result`] that is `Ok(())` when the chunk was collected.
554///
555/// # Errors
556/// The callback can return any [`FlowError`](crate::error::FlowError) to abort
557/// stream processing.
558pub type LlmCollectorFn = Box<dyn FnMut(Json) -> Result<()> + Send>;
559/// Finalizer used to synthesize the aggregate streaming response payload.
560///
561/// # Parameters
562/// This callback takes no arguments.
563///
564/// # Returns
565/// Aggregate response JSON synthesized from collected stream chunks.
566pub type LlmFinalizerFn = Box<dyn FnOnce() -> Json + Send>;
567/// Scope-local registry references passed into streaming execution-chain builders.
568///
569/// # Returns
570/// A shared reference to a scope-local streaming execution registry.
571pub(crate) type LlmStreamExecutionRegistryRef<'a> = &'a crate::registry::SortedRegistry<
572    crate::api::registry::ExecutionIntercept<LlmStreamExecutionFn>,
573>;
574/// Slice of scope-local streaming execution registries.
575///
576/// # Returns
577/// A borrowed slice of scope-local streaming execution registry references.
578pub(crate) type LlmStreamExecutionRegistryRefs<'a> = &'a [LlmStreamExecutionRegistryRef<'a>];
579
580/// Continuation type invoked by streaming LLM execution intercepts.
581///
582/// This callable represents the remainder of the streaming LLM execution chain
583/// and resolves to a stream of JSON response chunks.
584///
585/// # Parameters
586/// - First argument: LLM request to pass to the remaining streaming execution
587///   chain.
588///
589/// # Returns
590/// A future resolving to a JSON chunk stream.
591///
592/// # Errors
593/// The future resolves to an error when the remaining streaming execution
594/// chain fails.
595///
596/// # Lifetime
597/// This continuation can be called repeatedly or concurrently while its
598/// execution-intercept callback is still running. Each invocation receives an
599/// isolated snapshot of the scopes visible when `next` is called. Calls that
600/// remain unfinished or begin after the interceptor settles are rejected.
601/// Returning an interceptor stream extends that active lifetime until the
602/// stream closes, which permits lazy stream adapters to call `next` while they
603/// are being consumed. A stream successfully returned by `next` keeps its
604/// ordinary stream lifetime.
605pub type LlmStreamExecutionNextFn = Arc<
606    dyn Fn(LlmRequest) -> Pin<Box<dyn Future<Output = Result<LlmJsonStream>> + Send>> + Send + Sync,
607>;
608/// Wrap or replace streaming LLM execution.
609///
610/// A streaming execution intercept can observe or modify the request before
611/// invoking the continuation, and it can also replace the returned stream.
612///
613/// # Parameters
614/// - First argument: Logical provider or model family name.
615/// - Second argument: Current LLM request.
616/// - Third argument: Continuation for the remaining streaming execution chain.
617///
618/// # Returns
619/// A future resolving to a JSON chunk stream.
620///
621/// # Errors
622/// The future resolves to an error when the intercept or remaining streaming
623/// execution chain fails.
624pub type LlmStreamExecutionFn = Arc<
625    dyn Fn(
626            &str,
627            LlmRequest,
628            LlmStreamExecutionNextFn,
629        ) -> Pin<Box<dyn Future<Output = Result<LlmJsonStream>> + Send>>
630        + Send
631        + Sync,
632>;
633
634/// Consume runtime lifecycle events after they are emitted.
635///
636/// Event subscribers are invoked for scope, tool, LLM, and mark events after
637/// the runtime has built the final event payload.
638///
639/// # Parameters
640/// - First argument: Runtime event that was just emitted.
641///
642/// # Returns
643/// `()`.
644pub type EventSubscriberFn = Arc<dyn Fn(&Event) + Send + Sync>;