Skip to main content

nemo_relay_plugin/
async_sdk.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Future-based typed middleware adapters for native ABI v4.
5
6use std::collections::BTreeMap;
7use std::ffi::c_void;
8use std::future::Future;
9use std::marker::PhantomData;
10use std::panic::{AssertUnwindSafe, catch_unwind};
11use std::pin::Pin;
12use std::ptr;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, Mutex};
15use std::task::{Context, Poll};
16use std::time::Duration;
17
18use futures::{FutureExt, Stream};
19use serde::Deserialize;
20use serde_json::{Map, Value as Json};
21use tokio::runtime::{Handle, Runtime};
22use tokio_util::task::TaskTracker;
23
24use super::*;
25
26const CANCELLATION_POLL_INTERVAL: Duration = Duration::from_millis(10);
27const CANCELLATION_POLL_MAX_INTERVAL: Duration = Duration::from_millis(160);
28
29/// Configuration for the executor owned by one exported native plugin.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct NativeExecutorConfig {
32    /// Number of Tokio worker threads dedicated to the plugin.
33    pub worker_threads: usize,
34}
35
36impl Default for NativeExecutorConfig {
37    fn default() -> Self {
38        Self { worker_threads: 2 }
39    }
40}
41
42impl NativeExecutorConfig {
43    /// Applies an optional component-local executor override.
44    ///
45    /// Relay passes `[plugins.dynamic.config.executor]` as the `executor`
46    /// object in `plugin_config`. The only supported setting is the positive
47    /// integer `worker_threads`.
48    pub fn with_component_config(mut self, plugin_config: &Map<String, Json>) -> Result<Self> {
49        let Some(executor) = plugin_config.get("executor") else {
50            return self.validate();
51        };
52        let executor = executor
53            .as_object()
54            .ok_or_else(|| "executor configuration must be an object".to_string())?;
55        if let Some(worker_threads) = executor.get("worker_threads") {
56            let worker_threads = worker_threads
57                .as_u64()
58                .and_then(|value| usize::try_from(value).ok())
59                .ok_or_else(|| "executor.worker_threads must be a positive integer".to_string())?;
60            self.worker_threads = worker_threads;
61        }
62        self.validate()
63    }
64
65    fn validate(self) -> Result<Self> {
66        if self.worker_threads == 0 {
67            Err("executor.worker_threads must be greater than zero".into())
68        } else {
69            Ok(self)
70        }
71    }
72}
73
74pub(crate) struct NativeExecutor {
75    config: NativeExecutorConfig,
76    thread_name: String,
77    runtime: Mutex<Option<Runtime>>,
78    tracker: TaskTracker,
79    accepting: AtomicBool,
80}
81
82impl NativeExecutor {
83    pub(crate) fn new(config: NativeExecutorConfig, plugin_kind: &str) -> Arc<Self> {
84        Arc::new(Self {
85            config,
86            thread_name: format!("nemo-relay-plugin-{plugin_kind}"),
87            runtime: Mutex::new(None),
88            tracker: TaskTracker::new(),
89            accepting: AtomicBool::new(true),
90        })
91    }
92
93    fn ensure_started(&self) -> Result<Handle> {
94        if !self.accepting.load(Ordering::Acquire) {
95            return Err("native plugin executor is shutting down".into());
96        }
97        let mut runtime = self
98            .runtime
99            .lock()
100            .unwrap_or_else(|error| error.into_inner());
101        if runtime.is_none() {
102            let built = tokio::runtime::Builder::new_multi_thread()
103                .worker_threads(self.config.worker_threads)
104                .thread_name(self.thread_name.clone())
105                .enable_all()
106                .build()
107                .map_err(|error| format!("failed to start native plugin executor: {error}"))?;
108            *runtime = Some(built);
109        }
110        Ok(runtime
111            .as_ref()
112            .expect("runtime was initialized")
113            .handle()
114            .clone())
115    }
116
117    fn spawn(&self, future: impl Future<Output = ()> + Send + 'static) -> Result<()> {
118        let handle = self.ensure_started()?;
119        self.tracker.spawn_on(future, &handle);
120        Ok(())
121    }
122}
123
124impl Drop for NativeExecutor {
125    fn drop(&mut self) {
126        self.accepting.store(false, Ordering::Release);
127        self.tracker.close();
128        let runtime = self
129            .runtime
130            .get_mut()
131            .unwrap_or_else(|error| error.into_inner())
132            .take();
133        if let Some(runtime) = runtime {
134            let tracker = self.tracker.clone();
135            // Callback deregistration can run from a Relay Tokio worker. Drain
136            // and destroy this separate runtime on an OS thread so teardown
137            // never starts or drops a runtime from within another runtime. The
138            // join keeps callback state, including the native-library guard,
139            // alive until every accepted middleware task has finished.
140            std::thread::Builder::new()
141                .name(format!("{}-shutdown", self.thread_name))
142                .spawn(move || runtime.block_on(tracker.wait()))
143                .expect("native plugin executor shutdown thread should start")
144                .join()
145                .expect("native plugin executor shutdown thread should not panic");
146        }
147    }
148}
149
150#[derive(Clone, Copy)]
151struct HostV4(NemoRelayNativeHostApiV4);
152
153unsafe impl Send for HostV4 {}
154unsafe impl Sync for HostV4 {}
155
156struct Completion {
157    host: HostV4,
158    raw: *const NemoRelayNativeAsyncCompletion,
159}
160
161unsafe impl Send for Completion {}
162unsafe impl Sync for Completion {}
163
164impl Completion {
165    fn resolve<T: Serialize>(&self, value: &T) -> Result<()> {
166        let value = HostString::from_json(&self.host.0.v3.v1, value)
167            .ok_or_else(|| "failed to serialize native async middleware result".to_string())?;
168        let status =
169            unsafe { (self.host.0.v3.async_completion_resolve_json)(self.raw, value.as_ptr()) };
170        status_result(status, "resolve native async middleware completion")
171    }
172
173    fn reject(&self, message: &str) {
174        if let Some(message) = HostString::new(&self.host.0.v3.v1, message) {
175            unsafe {
176                (self.host.0.v3.async_completion_reject)(self.raw, message.as_ptr());
177            }
178        }
179    }
180
181    fn is_cancelled(&self) -> bool {
182        unsafe { (self.host.0.v3.async_completion_is_cancelled)(self.raw) }
183    }
184}
185
186impl Drop for Completion {
187    fn drop(&mut self) {
188        unsafe { (self.host.0.v3.async_completion_release)(self.raw) };
189    }
190}
191
192#[derive(Clone, Copy)]
193struct CompletionRef {
194    host: HostV4,
195    raw: *const NemoRelayNativeAsyncCompletion,
196}
197
198unsafe impl Send for CompletionRef {}
199
200impl CompletionRef {
201    fn request_context(
202        self,
203        codec: LlmCodecIdentity,
204    ) -> Result<LlmSanitizeRequestContext<'static>> {
205        let resolved = if matches!(codec, LlmCodecIdentity::None) {
206            None
207        } else {
208            let status = unsafe { (self.host.0.async_completion_retain)(self.raw) };
209            status_result(status, "retain native async completion capability")?;
210            Some(LlmSanitizeRequestCodec {
211                async_host: self.host.0,
212                completion: self.raw,
213                completion_release: self.host.0.v3.async_completion_release,
214                _lifetime: PhantomData,
215            })
216        };
217        Ok(LlmSanitizeRequestContext { codec, resolved })
218    }
219
220    fn response_context(
221        self,
222        codec: LlmCodecIdentity,
223    ) -> Result<LlmSanitizeResponseContext<'static>> {
224        let resolved = if matches!(codec, LlmCodecIdentity::None) {
225            None
226        } else {
227            let status = unsafe { (self.host.0.async_completion_retain)(self.raw) };
228            status_result(status, "retain native async completion capability")?;
229            Some(LlmSanitizeResponseCodec {
230                async_host: self.host.0,
231                completion: self.raw,
232                completion_release: self.host.0.v3.async_completion_release,
233                _lifetime: PhantomData,
234            })
235        };
236        Ok(LlmSanitizeResponseContext { codec, resolved })
237    }
238}
239
240struct NextInner {
241    host: HostV4,
242    raw: *const NemoRelayNativeAsyncNext,
243}
244
245unsafe impl Send for NextInner {}
246unsafe impl Sync for NextInner {}
247
248impl Drop for NextInner {
249    fn drop(&mut self) {
250        unsafe { (self.host.0.v3.async_next_release)(self.raw) };
251    }
252}
253
254/// Cloneable asynchronous tool execution continuation.
255#[derive(Clone)]
256pub struct ToolNext(Arc<NextInner>);
257
258impl ToolNext {
259    /// Continues the tool chain with replacement arguments.
260    pub async fn call(&self, args: Json) -> Result<ToolExecutionResult> {
261        let result = invoke_unary_next(&self.0, &args).await?;
262        serde_json::from_value(result)
263            .map_err(|error| format!("invalid canonical tool execution result: {error}"))
264    }
265}
266
267/// Cloneable asynchronous LLM execution continuation.
268#[derive(Clone)]
269pub struct LlmNext(Arc<NextInner>);
270
271impl LlmNext {
272    /// Continues the LLM chain with a replacement request.
273    pub async fn call(&self, request: LlmRequest) -> Result<Json> {
274        invoke_unary_next(&self.0, &request).await
275    }
276}
277
278/// Asynchronous JSON stream returned by a native typed stream interceptor.
279pub type LlmJsonAsyncStream = Pin<Box<dyn Stream<Item = Result<Json>> + Send>>;
280
281/// Cloneable asynchronous LLM stream continuation.
282#[derive(Clone)]
283pub struct LlmStreamNext(Arc<NextInner>);
284
285impl LlmStreamNext {
286    /// Opens an independent pull-based downstream stream.
287    pub async fn call(&self, request: LlmRequest) -> Result<LlmJsonAsyncStream> {
288        let request = HostString::from_json(&self.0.host.0.v3.v1, &request)
289            .ok_or_else(|| "failed to serialize LLM stream request".to_string())?;
290        let (sender, receiver) = futures::channel::oneshot::channel();
291        let callback_state = Box::into_raw(Box::new(OpenState {
292            sender,
293            host: self.0.host,
294        }));
295        let status = unsafe {
296            (self.0.host.0.async_next_open_llm_stream)(
297                self.0.raw,
298                request.as_ptr(),
299                open_stream_callback,
300                callback_state.cast(),
301            )
302        };
303        if status != NemoRelayStatus::Ok {
304            drop(unsafe { Box::from_raw(callback_state) });
305            return Err(status_message(
306                &self.0.host.0.v3.v1,
307                status,
308                "open LLM stream",
309            ));
310        }
311        let mut opened = receiver
312            .await
313            .map_err(|_| "LLM stream open callback was dropped".to_string())??;
314        let raw = opened.take();
315        Ok(Box::pin(PullStream {
316            host: self.0.host,
317            raw,
318            pending: None,
319            finished: false,
320        }))
321    }
322}
323
324struct OpenedStream {
325    host: HostV4,
326    raw: *const NemoRelayNativeLlmAsyncStream,
327}
328unsafe impl Send for OpenedStream {}
329
330impl OpenedStream {
331    fn take(&mut self) -> *const NemoRelayNativeLlmAsyncStream {
332        std::mem::replace(&mut self.raw, ptr::null())
333    }
334}
335
336impl Drop for OpenedStream {
337    fn drop(&mut self) {
338        if !self.raw.is_null() {
339            unsafe {
340                (self.host.0.async_llm_stream_cancel)(self.raw);
341                (self.host.0.async_llm_stream_release)(self.raw);
342            }
343        }
344    }
345}
346type OpenSender = futures::channel::oneshot::Sender<Result<OpenedStream>>;
347struct OpenState {
348    sender: OpenSender,
349    host: HostV4,
350}
351
352unsafe extern "C" fn open_stream_callback(
353    user_data: *mut c_void,
354    stream: *const NemoRelayNativeLlmAsyncStream,
355    error: *const NemoRelayNativeString,
356) {
357    let state = unsafe { Box::from_raw(user_data.cast::<OpenState>()) };
358    let result = if !stream.is_null() {
359        Ok(OpenedStream {
360            host: state.host,
361            raw: stream,
362        })
363    } else if !error.is_null() {
364        Err(read_host_string(&state.host.0.v3.v1, error)
365            .unwrap_or_else(|_| "failed to open LLM stream".into()))
366    } else {
367        Err("host returned neither an LLM stream nor an error".into())
368    };
369    let _ = state.sender.send(result);
370}
371
372struct PullStream {
373    host: HostV4,
374    raw: *const NemoRelayNativeLlmAsyncStream,
375    pending: Option<futures::channel::oneshot::Receiver<Result<Option<Json>>>>,
376    finished: bool,
377}
378
379unsafe impl Send for PullStream {}
380
381impl Stream for PullStream {
382    type Item = Result<Json>;
383
384    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
385        if self.finished {
386            return Poll::Ready(None);
387        }
388        if self.pending.is_none() {
389            let (sender, receiver) = futures::channel::oneshot::channel();
390            let callback_state = Box::into_raw(Box::new(PullState {
391                sender,
392                host: self.host,
393            }));
394            let status = unsafe {
395                (self.host.0.async_llm_stream_pull)(
396                    self.raw,
397                    pull_stream_callback,
398                    callback_state.cast(),
399                )
400            };
401            if status != NemoRelayStatus::Ok {
402                drop(unsafe { Box::from_raw(callback_state) });
403                self.finished = true;
404                return Poll::Ready(Some(Err(status_message(
405                    &self.host.0.v3.v1,
406                    status,
407                    "pull LLM stream",
408                ))));
409            }
410            self.pending = Some(receiver);
411        }
412        let pending = self
413            .pending
414            .as_mut()
415            .expect("pull receiver was initialized");
416        match Pin::new(pending).poll(cx) {
417            Poll::Pending => Poll::Pending,
418            Poll::Ready(result) => {
419                self.pending = None;
420                match result {
421                    Ok(Ok(Some(chunk))) => Poll::Ready(Some(Ok(chunk))),
422                    Ok(Ok(None)) => {
423                        self.finished = true;
424                        Poll::Ready(None)
425                    }
426                    Ok(Err(error)) => {
427                        self.finished = true;
428                        Poll::Ready(Some(Err(error)))
429                    }
430                    Err(_) => {
431                        self.finished = true;
432                        Poll::Ready(Some(Err("LLM stream pull callback was dropped".into())))
433                    }
434                }
435            }
436        }
437    }
438}
439
440impl Drop for PullStream {
441    fn drop(&mut self) {
442        if !self.finished {
443            unsafe { (self.host.0.async_llm_stream_cancel)(self.raw) };
444        }
445        unsafe { (self.host.0.async_llm_stream_release)(self.raw) };
446    }
447}
448
449type PullSender = futures::channel::oneshot::Sender<Result<Option<Json>>>;
450struct PullState {
451    sender: PullSender,
452    host: HostV4,
453}
454
455unsafe extern "C" fn pull_stream_callback(
456    user_data: *mut c_void,
457    chunk_json: *const NemoRelayNativeString,
458    error: *const NemoRelayNativeString,
459    done: bool,
460) {
461    let state = unsafe { Box::from_raw(user_data.cast::<PullState>()) };
462    let result = if !error.is_null() {
463        Err(read_host_string(&state.host.0.v3.v1, error)
464            .unwrap_or_else(|_| "LLM stream pull failed".into()))
465    } else if done {
466        Ok(None)
467    } else if !chunk_json.is_null() {
468        read_json_value(&state.host.0.v3.v1, chunk_json, "LLM stream chunk")
469            .map_err(|status| format!("invalid LLM stream chunk: {status:?}"))
470            .map(Some)
471    } else {
472        Err("host returned an invalid LLM stream pull result".into())
473    };
474    let _ = state.sender.send(result);
475}
476
477async fn invoke_unary_next<T: Serialize>(next: &NextInner, value: &T) -> Result<Json> {
478    let value = HostString::from_json(&next.host.0.v3.v1, value)
479        .ok_or_else(|| "failed to serialize native continuation input".to_string())?;
480    let (sender, receiver) = futures::channel::oneshot::channel();
481    let callback_state = Box::into_raw(Box::new(UnaryState {
482        sender,
483        host: next.host,
484    }));
485    let status = unsafe {
486        (next.host.0.v3.async_next_invoke_result)(
487            next.raw,
488            value.as_ptr(),
489            unary_next_callback,
490            callback_state.cast(),
491        )
492    };
493    if status != NemoRelayStatus::Ok {
494        drop(unsafe { Box::from_raw(callback_state) });
495        return Err(status_message(
496            &next.host.0.v3.v1,
497            status,
498            "invoke native continuation",
499        ));
500    }
501    receiver
502        .await
503        .map_err(|_| "native continuation callback was dropped".to_string())?
504}
505
506#[cfg(test)]
507#[path = "../tests/unit/async_sdk_tests.rs"]
508mod tests;
509
510type UnarySender = futures::channel::oneshot::Sender<Result<Json>>;
511struct UnaryState {
512    sender: UnarySender,
513    host: HostV4,
514}
515
516unsafe extern "C" fn unary_next_callback(
517    user_data: *mut c_void,
518    value_json: *const NemoRelayNativeString,
519    error: *const NemoRelayNativeString,
520) {
521    let state = unsafe { Box::from_raw(user_data.cast::<UnaryState>()) };
522    let result = if !error.is_null() {
523        Err(read_host_string(&state.host.0.v3.v1, error)
524            .unwrap_or_else(|_| "native continuation failed".into()))
525    } else {
526        read_json_value(
527            &state.host.0.v3.v1,
528            value_json,
529            "native continuation result",
530        )
531        .map_err(|status| format!("invalid native continuation result: {status:?}"))
532    };
533    let _ = state.sender.send(result);
534}
535
536type UnaryFuture = Pin<Box<dyn Future<Output = Result<Json>> + Send>>;
537type UnaryAdapter =
538    dyn Fn(Json, Option<Arc<NextInner>>, CompletionRef) -> UnaryFuture + Send + Sync;
539
540struct UnaryCallbackState {
541    host: HostV4,
542    executor: Arc<NativeExecutor>,
543    adapter: Box<UnaryAdapter>,
544}
545
546unsafe extern "C" fn drop_unary_callback(user_data: *mut c_void) {
547    if !user_data.is_null() {
548        drop(unsafe { Box::from_raw(user_data.cast::<UnaryCallbackState>()) });
549    }
550}
551
552unsafe extern "C" fn unary_trampoline(
553    user_data: *mut c_void,
554    invocation_json: *const NemoRelayNativeString,
555    next: *const NemoRelayNativeAsyncNext,
556    completion: *const NemoRelayNativeAsyncCompletion,
557) -> u32 {
558    let state = unsafe { &*user_data.cast::<UnaryCallbackState>() };
559    let completion = Completion {
560        host: state.host,
561        raw: completion,
562    };
563    let completion_ref = CompletionRef {
564        host: state.host,
565        raw: completion.raw,
566    };
567    let next = (!next.is_null()).then(|| {
568        Arc::new(NextInner {
569            host: state.host,
570            raw: next,
571        })
572    });
573    let invocation = read_json_value(&state.host.0.v3.v1, invocation_json, "async invocation")
574        .map_err(|status| format!("invalid async invocation: {status:?}"));
575    let binding = ScopePollBinding::capture(state.host.0.v3.v1);
576    let future = catch_unwind(AssertUnwindSafe(|| match invocation {
577        Ok(invocation) => (state.adapter)(invocation, next, completion_ref),
578        Err(error) => Box::pin(async move { Err(error) }) as UnaryFuture,
579    }));
580    if let Err(error) = state.executor.ensure_started() {
581        completion.reject(&error);
582        set_last_error(&state.host.0.v3.v1, &error);
583        return NemoRelayNativeAsyncCallbackState::Pending as u32;
584    }
585    let task = match future {
586        Ok(future) => drive_unary(future, binding, completion),
587        Err(_) => drive_unary(
588            Box::pin(async move { Err("typed native middleware callback panicked".into()) }),
589            binding,
590            completion,
591        ),
592    };
593    if let Err(error) = state.executor.spawn(async move {
594        let _ = task.await;
595    }) {
596        // A stopped executor cannot retain plugin code. The callback-owned
597        // handles have already been reclaimed by dropping `task`.
598        set_last_error(&state.host.0.v3.v1, &error);
599    }
600    NemoRelayNativeAsyncCallbackState::Pending as u32
601}
602
603fn drive_unary(
604    future: UnaryFuture,
605    binding: Result<ScopePollBinding>,
606    completion: Completion,
607) -> Pin<Box<dyn Future<Output = ()> + Send>> {
608    Box::pin(async move {
609        let future: UnaryFuture = match binding {
610            Ok(binding) => Box::pin(ScopedFuture::new(future, binding)),
611            Err(error) => {
612                completion.reject(&error);
613                return;
614            }
615        };
616        let result = tokio::select! {
617            result = AssertUnwindSafe(future).catch_unwind() => {
618                result.unwrap_or_else(|_| Err("typed native middleware future panicked".into()))
619            }
620            () = wait_for_completion_cancellation(&completion) => return,
621        };
622        match result {
623            Ok(value) => {
624                if let Err(error) = completion.resolve(&value) {
625                    completion.reject(&error);
626                }
627            }
628            Err(error) => completion.reject(&error),
629        }
630    })
631}
632
633async fn wait_for_completion_cancellation(completion: &Completion) {
634    let mut delay = CANCELLATION_POLL_INTERVAL;
635    loop {
636        tokio::time::sleep(delay).await;
637        if completion.is_cancelled() {
638            return;
639        }
640        delay = delay.saturating_mul(2).min(CANCELLATION_POLL_MAX_INTERVAL);
641    }
642}
643
644struct ScopePollBinding {
645    host: NemoRelayNativeHostApiV1,
646    captured: *mut NemoRelayNativeScopeStackBinding,
647}
648
649unsafe impl Send for ScopePollBinding {}
650
651impl ScopePollBinding {
652    fn capture(host: NemoRelayNativeHostApiV1) -> Result<Self> {
653        let mut captured = ptr::null_mut();
654        let status = unsafe { (host.scope_stack_capture_thread)(&mut captured) };
655        if status == NemoRelayStatus::Ok && !captured.is_null() {
656            Ok(Self { host, captured })
657        } else {
658            Err(format!(
659                "failed to capture native callback scope stack: {status:?}"
660            ))
661        }
662    }
663
664    fn enter(&mut self) -> Result<*mut NemoRelayNativeScopeStackBinding> {
665        let mut previous = ptr::null_mut();
666        let status = unsafe { (self.host.scope_stack_capture_thread)(&mut previous) };
667        if status != NemoRelayStatus::Ok || previous.is_null() {
668            return Err(format!(
669                "failed to capture executor scope stack: {status:?}"
670            ));
671        }
672        let captured = std::mem::replace(&mut self.captured, ptr::null_mut());
673        let status = unsafe { (self.host.scope_stack_restore_thread)(captured) };
674        if status != NemoRelayStatus::Ok {
675            unsafe { (self.host.scope_stack_binding_free)(previous) };
676            return Err(format!(
677                "failed to install callback scope stack: {status:?}"
678            ));
679        }
680        Ok(previous)
681    }
682
683    fn exit(&mut self, previous: *mut NemoRelayNativeScopeStackBinding) -> Result<()> {
684        let status = unsafe { (self.host.scope_stack_capture_thread)(&mut self.captured) };
685        if status != NemoRelayStatus::Ok || self.captured.is_null() {
686            let restore_status = unsafe { (self.host.scope_stack_restore_thread)(previous) };
687            if restore_status != NemoRelayStatus::Ok {
688                unsafe { (self.host.scope_stack_binding_free)(previous) };
689                return Err(format!(
690                    "failed to recapture callback scope stack: {status:?}; failed to restore executor scope stack: {restore_status:?}"
691                ));
692            }
693            return Err(format!(
694                "failed to recapture callback scope stack: {status:?}"
695            ));
696        }
697        let status = unsafe { (self.host.scope_stack_restore_thread)(previous) };
698        status_result(status, "restore executor scope stack")
699    }
700}
701
702struct ScopePollRestore<'a> {
703    binding: &'a mut ScopePollBinding,
704    previous: Option<*mut NemoRelayNativeScopeStackBinding>,
705}
706
707impl<'a> ScopePollRestore<'a> {
708    fn new(
709        binding: &'a mut ScopePollBinding,
710        previous: *mut NemoRelayNativeScopeStackBinding,
711    ) -> Self {
712        Self {
713            binding,
714            previous: Some(previous),
715        }
716    }
717
718    fn restore(&mut self) -> Result<()> {
719        let previous = self.previous.take().expect("scope binding restored once");
720        self.binding.exit(previous)
721    }
722}
723
724impl Drop for ScopePollRestore<'_> {
725    fn drop(&mut self) {
726        if let Some(previous) = self.previous.take() {
727            // Do not let a panic from the wrapped future leave its scope stack
728            // on an SDK executor worker. Drop cannot report a restoration
729            // failure, but `exit` still restores the previous binding first.
730            let _ = self.binding.exit(previous);
731        }
732    }
733}
734
735impl Drop for ScopePollBinding {
736    fn drop(&mut self) {
737        if !self.captured.is_null() {
738            unsafe { (self.host.scope_stack_binding_free)(self.captured) };
739        }
740    }
741}
742
743struct ScopedFuture<F> {
744    future: F,
745    binding: ScopePollBinding,
746}
747
748impl<F> ScopedFuture<F> {
749    fn new(future: F, binding: ScopePollBinding) -> Self {
750        Self { future, binding }
751    }
752}
753
754impl<F: Future> Future for ScopedFuture<F> {
755    type Output = F::Output;
756
757    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
758        // SAFETY: neither field moves while `self` is pinned.
759        let this = unsafe { self.get_unchecked_mut() };
760        let previous = this
761            .binding
762            .enter()
763            .unwrap_or_else(|error| panic!("{error}"));
764        let mut restore = ScopePollRestore::new(&mut this.binding, previous);
765        let result = unsafe { Pin::new_unchecked(&mut this.future) }.poll(cx);
766        restore.restore().unwrap_or_else(|error| panic!("{error}"));
767        result
768    }
769}
770
771struct ScopedStream<S> {
772    stream: S,
773    binding: ScopePollBinding,
774}
775
776impl<S> ScopedStream<S> {
777    fn new(stream: S, binding: ScopePollBinding) -> Self {
778        Self { stream, binding }
779    }
780}
781
782impl<S: Stream> Stream for ScopedStream<S> {
783    type Item = S::Item;
784
785    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
786        // SAFETY: neither field moves while `self` is pinned.
787        let this = unsafe { self.get_unchecked_mut() };
788        let previous = this
789            .binding
790            .enter()
791            .unwrap_or_else(|error| panic!("{error}"));
792        let mut restore = ScopePollRestore::new(&mut this.binding, previous);
793        let result = unsafe { Pin::new_unchecked(&mut this.stream) }.poll_next(cx);
794        restore.restore().unwrap_or_else(|error| panic!("{error}"));
795        result
796    }
797}
798
799#[derive(Deserialize)]
800struct NameValueInvocation {
801    name: String,
802    value: Json,
803}
804
805#[derive(Deserialize)]
806struct RequestInvocation {
807    request: LlmRequest,
808}
809
810#[derive(Deserialize)]
811struct NameRequestInvocation {
812    name: String,
813    request: LlmRequest,
814}
815
816#[derive(Deserialize)]
817struct LlmRequestInterceptInvocation {
818    name: String,
819    request: LlmRequest,
820    annotated: Option<AnnotatedLlmRequest>,
821}
822
823#[derive(Deserialize)]
824struct EventInvocation {
825    event: Event,
826    fields: EventSanitizeFields,
827}
828
829#[derive(Deserialize)]
830struct EventMetadataInvocation {
831    event: Event,
832}
833
834type StreamFuture = Pin<Box<dyn Future<Output = Result<LlmJsonAsyncStream>> + Send>>;
835type StreamAdapter = dyn Fn(Json, LlmStreamNext) -> StreamFuture + Send + Sync;
836
837struct StreamCallbackState {
838    host: HostV4,
839    executor: Arc<NativeExecutor>,
840    adapter: Box<StreamAdapter>,
841}
842
843unsafe extern "C" fn drop_stream_callback(user_data: *mut c_void) {
844    if !user_data.is_null() {
845        drop(unsafe { Box::from_raw(user_data.cast::<StreamCallbackState>()) });
846    }
847}
848
849struct OutputStream {
850    host: HostV4,
851    raw: *const NemoRelayNativeAsyncStream,
852}
853
854unsafe impl Send for OutputStream {}
855unsafe impl Sync for OutputStream {}
856
857impl OutputStream {
858    fn cancelled(&self) -> bool {
859        unsafe { (self.host.0.v3.async_stream_is_cancelled)(self.raw) }
860    }
861
862    async fn push(&self, value: &Json) -> Result<()> {
863        let value = HostString::from_json(&self.host.0.v3.v1, value)
864            .ok_or_else(|| "failed to serialize native stream chunk".to_string())?;
865        loop {
866            if self.cancelled() {
867                return Err("native stream consumer cancelled".into());
868            }
869            let status =
870                unsafe { (self.host.0.v3.async_stream_push_json)(self.raw, value.as_ptr()) };
871            match status {
872                NemoRelayStatus::Ok => return Ok(()),
873                NemoRelayStatus::Backpressured => {
874                    tokio::time::sleep(CANCELLATION_POLL_INTERVAL).await;
875                }
876                status => return Err(format!("push native stream chunk failed: {status:?}")),
877            }
878        }
879    }
880
881    fn finish(&self) -> Result<()> {
882        status_result(
883            unsafe { (self.host.0.v3.async_stream_finish)(self.raw) },
884            "finish native stream",
885        )
886    }
887
888    async fn reject(&self, error: &str) {
889        if let Some(error) = HostString::new(&self.host.0.v3.v1, error) {
890            loop {
891                if self.cancelled() {
892                    break;
893                }
894                let status =
895                    unsafe { (self.host.0.v3.async_stream_reject)(self.raw, error.as_ptr()) };
896                match status {
897                    NemoRelayStatus::Backpressured => {
898                        tokio::time::sleep(CANCELLATION_POLL_INTERVAL).await;
899                    }
900                    _ => break,
901                }
902            }
903        }
904    }
905
906    fn reject_once(&self, error: &str) {
907        if let Some(error) = HostString::new(&self.host.0.v3.v1, error) {
908            unsafe {
909                (self.host.0.v3.async_stream_reject)(self.raw, error.as_ptr());
910            }
911        }
912    }
913}
914
915impl Drop for OutputStream {
916    fn drop(&mut self) {
917        unsafe { (self.host.0.v3.async_stream_release)(self.raw) };
918    }
919}
920
921unsafe extern "C" fn stream_trampoline(
922    user_data: *mut c_void,
923    invocation_json: *const NemoRelayNativeString,
924    next: *const NemoRelayNativeAsyncNext,
925    stream: *const NemoRelayNativeAsyncStream,
926) -> u32 {
927    let state = unsafe { &*user_data.cast::<StreamCallbackState>() };
928    let output = OutputStream {
929        host: state.host,
930        raw: stream,
931    };
932    if next.is_null() {
933        output.reject_once("native stream middleware requires a continuation");
934        return NemoRelayNativeAsyncCallbackState::Pending as u32;
935    }
936    let next = LlmStreamNext(Arc::new(NextInner {
937        host: state.host,
938        raw: next,
939    }));
940    let invocation = read_json_value(&state.host.0.v3.v1, invocation_json, "stream invocation")
941        .map_err(|status| format!("invalid native stream invocation: {status:?}"));
942    let bindings = ScopePollBinding::capture(state.host.0.v3.v1).and_then(|future| {
943        ScopePollBinding::capture(state.host.0.v3.v1).map(|stream| (future, stream))
944    });
945    let future = catch_unwind(AssertUnwindSafe(|| match invocation {
946        Ok(invocation) => (state.adapter)(invocation, next),
947        Err(error) => Box::pin(async move { Err(error) }) as StreamFuture,
948    }));
949    let future = future.unwrap_or_else(|_| {
950        Box::pin(async move { Err("typed native stream callback panicked".into()) })
951    });
952    if let Err(error) = state.executor.ensure_started() {
953        output.reject_once(&error);
954        set_last_error(&state.host.0.v3.v1, &error);
955        return NemoRelayNativeAsyncCallbackState::Pending as u32;
956    }
957    let task = async move {
958        let (future_binding, stream_binding) = match bindings {
959            Ok(bindings) => bindings,
960            Err(error) => {
961                output.reject(&error).await;
962                return;
963            }
964        };
965        let future: StreamFuture = Box::pin(ScopedFuture::new(future, future_binding));
966        let stream = tokio::select! {
967            result = AssertUnwindSafe(future).catch_unwind() => match result {
968                Ok(result) => result,
969                Err(_) => Err("typed native stream future panicked".into()),
970            },
971            () = wait_for_stream_cancellation(&output) => return,
972        };
973        let mut stream = match stream {
974            Ok(stream) => ScopedStream::new(stream, stream_binding),
975            Err(error) => {
976                output.reject(&error).await;
977                return;
978            }
979        };
980        loop {
981            let item = tokio::select! {
982                result = AssertUnwindSafe(futures::StreamExt::next(&mut stream)).catch_unwind() => {
983                    match result {
984                        Ok(item) => item,
985                        Err(_) => {
986                            output.reject("typed native stream panicked while polling").await;
987                            return;
988                        }
989                    }
990                },
991                () = wait_for_stream_cancellation(&output) => return,
992            };
993            let Some(item) = item else {
994                break;
995            };
996            match item {
997                Ok(chunk) => {
998                    if let Err(error) = output.push(&chunk).await {
999                        if !output.cancelled() {
1000                            output.reject(&error).await;
1001                        }
1002                        return;
1003                    }
1004                }
1005                Err(error) => {
1006                    output.reject(&error).await;
1007                    return;
1008                }
1009            }
1010        }
1011        if !output.cancelled() {
1012            let _ = output.finish();
1013        }
1014    };
1015    if let Err(error) = state.executor.spawn(task) {
1016        set_last_error(&state.host.0.v3.v1, &error);
1017    }
1018    NemoRelayNativeAsyncCallbackState::Pending as u32
1019}
1020
1021async fn wait_for_stream_cancellation(output: &OutputStream) {
1022    let mut delay = CANCELLATION_POLL_INTERVAL;
1023    loop {
1024        tokio::time::sleep(delay).await;
1025        if output.cancelled() {
1026            return;
1027        }
1028        delay = delay.saturating_mul(2).min(CANCELLATION_POLL_MAX_INTERVAL);
1029    }
1030}
1031
1032#[derive(Deserialize)]
1033struct CodecInvocation<T> {
1034    #[serde(flatten)]
1035    payload: T,
1036    context: CodecIdentityInvocation,
1037}
1038
1039#[derive(Deserialize)]
1040struct CodecIdentityInvocation {
1041    codec_kind: String,
1042    codec_id: Option<String>,
1043}
1044
1045impl CodecIdentityInvocation {
1046    fn identity(self) -> Result<LlmCodecIdentity> {
1047        match (self.codec_kind.as_str(), self.codec_id) {
1048            ("none", _) => Ok(LlmCodecIdentity::None),
1049            ("opaque", _) => Ok(LlmCodecIdentity::Opaque),
1050            ("builtin", Some(id)) => BuiltinLlmCodec::from_id(&id)
1051                .map(LlmCodecIdentity::BuiltIn)
1052                .ok_or_else(|| format!("unknown built-in LLM codec: {id}")),
1053            ("runtime", Some(id)) => Ok(LlmCodecIdentity::Runtime(id)),
1054            (kind, _) => Err(format!("invalid LLM codec context: {kind}")),
1055        }
1056    }
1057}
1058
1059impl PluginContext<'_> {
1060    fn host_v4(&self) -> Result<HostV4> {
1061        if self.host.abi_version < NEMO_RELAY_NATIVE_ABI_VERSION
1062            || self.host.struct_size < std::mem::size_of::<NemoRelayNativeHostApiV4>()
1063        {
1064            return Err("typed async native middleware requires Relay ABI v4".into());
1065        }
1066        Ok(HostV4(unsafe {
1067            *(self.host as *const _ as *const NemoRelayNativeHostApiV4)
1068        }))
1069    }
1070
1071    fn register_unary_adapter(
1072        &mut self,
1073        kind: NemoRelayNativeAsyncMiddlewareKind,
1074        name: &str,
1075        priority: i32,
1076        break_chain: bool,
1077        adapter: Box<UnaryAdapter>,
1078    ) -> Result<()> {
1079        let state = Box::into_raw(Box::new(UnaryCallbackState {
1080            host: self.host_v4()?,
1081            executor: Arc::clone(&self.executor),
1082            adapter,
1083        }));
1084        let status = unsafe {
1085            self.register_async_middleware_raw(
1086                kind,
1087                name,
1088                priority,
1089                break_chain,
1090                unary_trampoline,
1091                state.cast(),
1092                Some(drop_unary_callback),
1093            )
1094        };
1095        if status == NemoRelayStatus::Ok {
1096            Ok(())
1097        } else {
1098            Err(status_message(
1099                self.host,
1100                status,
1101                registration_operation(kind),
1102            ))
1103        }
1104    }
1105
1106    fn register_event_adapter<F, Fut>(
1107        &mut self,
1108        kind: NemoRelayNativeAsyncMiddlewareKind,
1109        name: &str,
1110        priority: i32,
1111        callback: F,
1112    ) -> Result<()>
1113    where
1114        F: Fn(Arc<Event>, EventSanitizeFields) -> Fut + Send + Sync + 'static,
1115        Fut: Future<Output = Result<EventSanitizeFields>> + Send + 'static,
1116    {
1117        let callback = Arc::new(callback);
1118        self.register_unary_adapter(
1119            kind,
1120            name,
1121            priority,
1122            false,
1123            Box::new(move |value, _, _| {
1124                let callback = Arc::clone(&callback);
1125                Box::pin(async move {
1126                    let invocation: EventInvocation = serde_json::from_value(value)
1127                        .map_err(|error| format!("invalid event sanitizer invocation: {error}"))?;
1128                    serde_json::to_value(
1129                        callback(Arc::new(invocation.event), invocation.fields).await?,
1130                    )
1131                    .map_err(|error| error.to_string())
1132                })
1133            }),
1134        )
1135    }
1136
1137    /// Registers an asynchronous Event metadata injector.
1138    pub fn register_event_metadata_injector<F, Fut>(
1139        &mut self,
1140        name: &str,
1141        priority: i32,
1142        callback: F,
1143    ) -> Result<()>
1144    where
1145        F: Fn(Arc<Event>) -> Fut + Send + Sync + 'static,
1146        Fut: Future<Output = Result<BTreeMap<String, Json>>> + Send + 'static,
1147    {
1148        let callback = Arc::new(callback);
1149        self.register_unary_adapter(
1150            NemoRelayNativeAsyncMiddlewareKind::EventMetadataInjector,
1151            name,
1152            priority,
1153            false,
1154            Box::new(move |value, _, _| {
1155                let callback = Arc::clone(&callback);
1156                Box::pin(async move {
1157                    let invocation: EventMetadataInvocation = serde_json::from_value(value)
1158                        .map_err(|error| {
1159                            format!("invalid Event metadata injector invocation: {error}")
1160                        })?;
1161                    serde_json::to_value(callback(Arc::new(invocation.event)).await?)
1162                        .map_err(|error| error.to_string())
1163                })
1164            }),
1165        )
1166    }
1167
1168    /// Registers an asynchronous mark-event sanitizer.
1169    pub fn register_mark_sanitize_guardrail<F, Fut>(
1170        &mut self,
1171        name: &str,
1172        priority: i32,
1173        callback: F,
1174    ) -> Result<()>
1175    where
1176        F: Fn(Arc<Event>, EventSanitizeFields) -> Fut + Send + Sync + 'static,
1177        Fut: Future<Output = Result<EventSanitizeFields>> + Send + 'static,
1178    {
1179        self.register_event_adapter(
1180            NemoRelayNativeAsyncMiddlewareKind::MarkSanitize,
1181            name,
1182            priority,
1183            callback,
1184        )
1185    }
1186
1187    /// Registers an asynchronous scope-start sanitizer.
1188    pub fn register_scope_sanitize_start_guardrail<F, Fut>(
1189        &mut self,
1190        name: &str,
1191        priority: i32,
1192        callback: F,
1193    ) -> Result<()>
1194    where
1195        F: Fn(Arc<Event>, EventSanitizeFields) -> Fut + Send + Sync + 'static,
1196        Fut: Future<Output = Result<EventSanitizeFields>> + Send + 'static,
1197    {
1198        self.register_event_adapter(
1199            NemoRelayNativeAsyncMiddlewareKind::ScopeSanitizeStart,
1200            name,
1201            priority,
1202            callback,
1203        )
1204    }
1205
1206    /// Registers an asynchronous scope-end sanitizer.
1207    pub fn register_scope_sanitize_end_guardrail<F, Fut>(
1208        &mut self,
1209        name: &str,
1210        priority: i32,
1211        callback: F,
1212    ) -> Result<()>
1213    where
1214        F: Fn(Arc<Event>, EventSanitizeFields) -> Fut + Send + Sync + 'static,
1215        Fut: Future<Output = Result<EventSanitizeFields>> + Send + 'static,
1216    {
1217        self.register_event_adapter(
1218            NemoRelayNativeAsyncMiddlewareKind::ScopeSanitizeEnd,
1219            name,
1220            priority,
1221            callback,
1222        )
1223    }
1224
1225    fn register_tool_json_adapter<F, Fut>(
1226        &mut self,
1227        kind: NemoRelayNativeAsyncMiddlewareKind,
1228        name: &str,
1229        priority: i32,
1230        break_chain: bool,
1231        callback: F,
1232    ) -> Result<()>
1233    where
1234        F: Fn(String, Json) -> Fut + Send + Sync + 'static,
1235        Fut: Future<Output = Result<Json>> + Send + 'static,
1236    {
1237        let callback = Arc::new(callback);
1238        self.register_unary_adapter(
1239            kind,
1240            name,
1241            priority,
1242            break_chain,
1243            Box::new(move |value, _, _| {
1244                let callback = Arc::clone(&callback);
1245                Box::pin(async move {
1246                    let invocation: NameValueInvocation =
1247                        serde_json::from_value(value).map_err(|error| error.to_string())?;
1248                    callback(invocation.name, invocation.value).await
1249                })
1250            }),
1251        )
1252    }
1253
1254    /// Registers an asynchronous tool request sanitizer.
1255    pub fn register_tool_sanitize_request_guardrail<F, Fut>(
1256        &mut self,
1257        name: &str,
1258        priority: i32,
1259        callback: F,
1260    ) -> Result<()>
1261    where
1262        F: Fn(String, Json) -> Fut + Send + Sync + 'static,
1263        Fut: Future<Output = Result<Json>> + Send + 'static,
1264    {
1265        self.register_tool_json_adapter(
1266            NemoRelayNativeAsyncMiddlewareKind::ToolSanitizeRequest,
1267            name,
1268            priority,
1269            false,
1270            callback,
1271        )
1272    }
1273
1274    /// Registers an asynchronous tool response sanitizer.
1275    pub fn register_tool_sanitize_response_guardrail<F, Fut>(
1276        &mut self,
1277        name: &str,
1278        priority: i32,
1279        callback: F,
1280    ) -> Result<()>
1281    where
1282        F: Fn(String, Json) -> Fut + Send + Sync + 'static,
1283        Fut: Future<Output = Result<Json>> + Send + 'static,
1284    {
1285        self.register_tool_json_adapter(
1286            NemoRelayNativeAsyncMiddlewareKind::ToolSanitizeResponse,
1287            name,
1288            priority,
1289            false,
1290            callback,
1291        )
1292    }
1293
1294    /// Registers an asynchronous tool conditional-execution guardrail.
1295    pub fn register_tool_conditional_execution_guardrail<F, Fut>(
1296        &mut self,
1297        name: &str,
1298        priority: i32,
1299        callback: F,
1300    ) -> Result<()>
1301    where
1302        F: Fn(String, Json) -> Fut + Send + Sync + 'static,
1303        Fut: Future<Output = Result<Option<String>>> + Send + 'static,
1304    {
1305        let callback = Arc::new(callback);
1306        self.register_unary_adapter(
1307            NemoRelayNativeAsyncMiddlewareKind::ToolConditionalExecution,
1308            name,
1309            priority,
1310            false,
1311            Box::new(move |value, _, _| {
1312                let callback = Arc::clone(&callback);
1313                Box::pin(async move {
1314                    let invocation: NameValueInvocation =
1315                        serde_json::from_value(value).map_err(|error| error.to_string())?;
1316                    serde_json::to_value(callback(invocation.name, invocation.value).await?)
1317                        .map_err(|error| error.to_string())
1318                })
1319            }),
1320        )
1321    }
1322
1323    /// Registers an asynchronous tool request intercept.
1324    pub fn register_tool_request_intercept<F, Fut>(
1325        &mut self,
1326        name: &str,
1327        priority: i32,
1328        break_chain: bool,
1329        callback: F,
1330    ) -> Result<()>
1331    where
1332        F: Fn(String, Json) -> Fut + Send + Sync + 'static,
1333        Fut: Future<Output = Result<Json>> + Send + 'static,
1334    {
1335        self.register_tool_json_adapter(
1336            NemoRelayNativeAsyncMiddlewareKind::ToolRequestIntercept,
1337            name,
1338            priority,
1339            break_chain,
1340            callback,
1341        )
1342    }
1343
1344    /// Registers an asynchronous tool execution intercept.
1345    pub fn register_tool_execution_intercept<F, Fut>(
1346        &mut self,
1347        name: &str,
1348        priority: i32,
1349        callback: F,
1350    ) -> Result<()>
1351    where
1352        F: Fn(String, Json, ToolNext) -> Fut + Send + Sync + 'static,
1353        Fut: Future<Output = Result<ToolExecutionInterceptOutcome>> + Send + 'static,
1354    {
1355        let callback = Arc::new(callback);
1356        self.register_unary_adapter(
1357            NemoRelayNativeAsyncMiddlewareKind::ToolExecutionIntercept,
1358            name,
1359            priority,
1360            false,
1361            Box::new(move |value, next, _| {
1362                let callback = Arc::clone(&callback);
1363                Box::pin(async move {
1364                    let invocation: NameValueInvocation =
1365                        serde_json::from_value(value).map_err(|error| error.to_string())?;
1366                    let next = ToolNext(
1367                        next.ok_or_else(|| "tool execution continuation was null".to_string())?,
1368                    );
1369                    serde_json::to_value(callback(invocation.name, invocation.value, next).await?)
1370                        .map_err(|error| error.to_string())
1371                })
1372            }),
1373        )
1374    }
1375
1376    /// Registers an asynchronous LLM request sanitizer.
1377    pub fn register_llm_sanitize_request_guardrail<F, Fut>(
1378        &mut self,
1379        name: &str,
1380        priority: i32,
1381        callback: F,
1382    ) -> Result<()>
1383    where
1384        F: Fn(LlmRequest, LlmSanitizeRequestContext<'static>) -> Fut + Send + Sync + 'static,
1385        Fut: Future<Output = Result<Option<LlmRequest>>> + Send + 'static,
1386    {
1387        let callback = Arc::new(callback);
1388        self.register_unary_adapter(
1389            NemoRelayNativeAsyncMiddlewareKind::LlmSanitizeRequest,
1390            name,
1391            priority,
1392            false,
1393            Box::new(move |value, _, completion| {
1394                let callback = Arc::clone(&callback);
1395                Box::pin(async move {
1396                    #[derive(Deserialize)]
1397                    struct Payload {
1398                        request: LlmRequest,
1399                    }
1400                    let invocation: CodecInvocation<Payload> =
1401                        serde_json::from_value(value).map_err(|error| error.to_string())?;
1402                    let codec = invocation.context.identity()?;
1403                    let context = completion.request_context(codec)?;
1404                    serde_json::to_value(callback(invocation.payload.request, context).await?)
1405                        .map_err(|error| error.to_string())
1406                })
1407            }),
1408        )
1409    }
1410
1411    /// Registers an asynchronous LLM response sanitizer.
1412    pub fn register_llm_sanitize_response_guardrail<F, Fut>(
1413        &mut self,
1414        name: &str,
1415        priority: i32,
1416        callback: F,
1417    ) -> Result<()>
1418    where
1419        F: Fn(Json, LlmSanitizeResponseContext<'static>) -> Fut + Send + Sync + 'static,
1420        Fut: Future<Output = Result<Option<Json>>> + Send + 'static,
1421    {
1422        let callback = Arc::new(callback);
1423        self.register_unary_adapter(
1424            NemoRelayNativeAsyncMiddlewareKind::LlmSanitizeResponse,
1425            name,
1426            priority,
1427            false,
1428            Box::new(move |value, _, completion| {
1429                let callback = Arc::clone(&callback);
1430                Box::pin(async move {
1431                    #[derive(Deserialize)]
1432                    struct Payload {
1433                        response: Json,
1434                    }
1435                    let invocation: CodecInvocation<Payload> =
1436                        serde_json::from_value(value).map_err(|error| error.to_string())?;
1437                    let codec = invocation.context.identity()?;
1438                    let context = completion.response_context(codec)?;
1439                    serde_json::to_value(callback(invocation.payload.response, context).await?)
1440                        .map_err(|error| error.to_string())
1441                })
1442            }),
1443        )
1444    }
1445
1446    /// Registers an asynchronous LLM conditional-execution guardrail.
1447    pub fn register_llm_conditional_execution_guardrail<F, Fut>(
1448        &mut self,
1449        name: &str,
1450        priority: i32,
1451        callback: F,
1452    ) -> Result<()>
1453    where
1454        F: Fn(LlmRequest) -> Fut + Send + Sync + 'static,
1455        Fut: Future<Output = Result<Option<String>>> + Send + 'static,
1456    {
1457        let callback = Arc::new(callback);
1458        self.register_unary_adapter(
1459            NemoRelayNativeAsyncMiddlewareKind::LlmConditionalExecution,
1460            name,
1461            priority,
1462            false,
1463            Box::new(move |value, _, _| {
1464                let callback = Arc::clone(&callback);
1465                Box::pin(async move {
1466                    let invocation: RequestInvocation =
1467                        serde_json::from_value(value).map_err(|error| error.to_string())?;
1468                    serde_json::to_value(callback(invocation.request).await?)
1469                        .map_err(|error| error.to_string())
1470                })
1471            }),
1472        )
1473    }
1474
1475    /// Registers an asynchronous LLM request intercept.
1476    pub fn register_llm_request_intercept<F, Fut>(
1477        &mut self,
1478        name: &str,
1479        priority: i32,
1480        break_chain: bool,
1481        callback: F,
1482    ) -> Result<()>
1483    where
1484        F: Fn(String, LlmRequest, Option<AnnotatedLlmRequest>) -> Fut + Send + Sync + 'static,
1485        Fut: Future<Output = Result<LlmRequestInterceptOutcome>> + Send + 'static,
1486    {
1487        let callback = Arc::new(callback);
1488        self.register_unary_adapter(
1489            NemoRelayNativeAsyncMiddlewareKind::LlmRequestIntercept,
1490            name,
1491            priority,
1492            break_chain,
1493            Box::new(move |value, _, _| {
1494                let callback = Arc::clone(&callback);
1495                Box::pin(async move {
1496                    let invocation: LlmRequestInterceptInvocation =
1497                        serde_json::from_value(value).map_err(|error| error.to_string())?;
1498                    serde_json::to_value(
1499                        callback(invocation.name, invocation.request, invocation.annotated).await?,
1500                    )
1501                    .map_err(|error| error.to_string())
1502                })
1503            }),
1504        )
1505    }
1506
1507    /// Registers an asynchronous LLM execution intercept.
1508    pub fn register_llm_execution_intercept<F, Fut>(
1509        &mut self,
1510        name: &str,
1511        priority: i32,
1512        callback: F,
1513    ) -> Result<()>
1514    where
1515        F: Fn(String, LlmRequest, LlmNext) -> Fut + Send + Sync + 'static,
1516        Fut: Future<Output = Result<Json>> + Send + 'static,
1517    {
1518        let callback = Arc::new(callback);
1519        self.register_unary_adapter(
1520            NemoRelayNativeAsyncMiddlewareKind::LlmExecutionIntercept,
1521            name,
1522            priority,
1523            false,
1524            Box::new(move |value, next, _| {
1525                let callback = Arc::clone(&callback);
1526                Box::pin(async move {
1527                    let invocation: NameRequestInvocation =
1528                        serde_json::from_value(value).map_err(|error| error.to_string())?;
1529                    let next = LlmNext(
1530                        next.ok_or_else(|| "LLM execution continuation was null".to_string())?,
1531                    );
1532                    callback(invocation.name, invocation.request, next).await
1533                })
1534            }),
1535        )
1536    }
1537
1538    /// Registers an asynchronous LLM stream execution intercept.
1539    pub fn register_llm_stream_execution_intercept<F, Fut>(
1540        &mut self,
1541        name: &str,
1542        priority: i32,
1543        callback: F,
1544    ) -> Result<()>
1545    where
1546        F: Fn(String, LlmRequest, LlmStreamNext) -> Fut + Send + Sync + 'static,
1547        Fut: Future<Output = Result<LlmJsonAsyncStream>> + Send + 'static,
1548    {
1549        let callback = Arc::new(callback);
1550        let state = Box::into_raw(Box::new(StreamCallbackState {
1551            host: self.host_v4()?,
1552            executor: Arc::clone(&self.executor),
1553            adapter: Box::new(move |value, next| {
1554                let callback = Arc::clone(&callback);
1555                Box::pin(async move {
1556                    let invocation: NameRequestInvocation =
1557                        serde_json::from_value(value).map_err(|error| error.to_string())?;
1558                    callback(invocation.name, invocation.request, next).await
1559                })
1560            }),
1561        }));
1562        let status = unsafe {
1563            self.register_async_stream_middleware_raw(
1564                name,
1565                priority,
1566                stream_trampoline,
1567                state.cast(),
1568                Some(drop_stream_callback),
1569            )
1570        };
1571        if status == NemoRelayStatus::Ok {
1572            Ok(())
1573        } else {
1574            Err(status_message(
1575                self.host,
1576                status,
1577                "register typed async stream middleware",
1578            ))
1579        }
1580    }
1581}
1582
1583fn status_result(status: NemoRelayStatus, operation: &str) -> Result<()> {
1584    if status == NemoRelayStatus::Ok {
1585        Ok(())
1586    } else {
1587        Err(format!("{operation} failed: {status:?}"))
1588    }
1589}
1590
1591fn registration_operation(kind: NemoRelayNativeAsyncMiddlewareKind) -> &'static str {
1592    match kind {
1593        NemoRelayNativeAsyncMiddlewareKind::ToolSanitizeRequest => "tool request sanitizer",
1594        NemoRelayNativeAsyncMiddlewareKind::ToolSanitizeResponse => "tool response sanitizer",
1595        NemoRelayNativeAsyncMiddlewareKind::ToolConditionalExecution => {
1596            "tool conditional guardrail"
1597        }
1598        NemoRelayNativeAsyncMiddlewareKind::ToolRequestIntercept => "tool request intercept",
1599        NemoRelayNativeAsyncMiddlewareKind::ToolExecutionIntercept => "tool execution intercept",
1600        NemoRelayNativeAsyncMiddlewareKind::LlmSanitizeRequest => "LLM request sanitizer",
1601        NemoRelayNativeAsyncMiddlewareKind::LlmSanitizeResponse => "LLM response sanitizer",
1602        NemoRelayNativeAsyncMiddlewareKind::LlmConditionalExecution => "LLM conditional guardrail",
1603        NemoRelayNativeAsyncMiddlewareKind::LlmRequestIntercept => "LLM request intercept",
1604        NemoRelayNativeAsyncMiddlewareKind::LlmExecutionIntercept => "LLM execution intercept",
1605        NemoRelayNativeAsyncMiddlewareKind::LlmStreamExecutionIntercept => {
1606            "LLM stream execution intercept"
1607        }
1608        NemoRelayNativeAsyncMiddlewareKind::MarkSanitize => "mark sanitizer",
1609        NemoRelayNativeAsyncMiddlewareKind::ScopeSanitizeStart => "scope start sanitizer",
1610        NemoRelayNativeAsyncMiddlewareKind::ScopeSanitizeEnd => "scope end sanitizer",
1611        NemoRelayNativeAsyncMiddlewareKind::EventMetadataInjector => "Event metadata injector",
1612    }
1613}
1614
1615fn status_message(
1616    host: &NemoRelayNativeHostApiV1,
1617    status: NemoRelayStatus,
1618    operation: &str,
1619) -> String {
1620    let _ = host;
1621    format!("{operation} failed: {status:?}")
1622}