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>;