Skip to main content

llm_kernel/llm/
middleware.rs

1//! Middleware hooks for [`LLMClient`] request/response lifecycle.
2//!
3//! Provides an [`LLMClientMiddleware`] trait with hooks that fire before
4//! each request, after each successful response, and on each error.
5//!
6//! The [`MiddlewareClient`] wrapper composes with any [`LLMClient`],
7//! including [`RetryClient`](crate::llm::retry::RetryClient).
8//!
9//! # Example
10//!
11//! ```ignore
12//! use llm_kernel::llm::{LLMClient, MiddlewareClient, NoopMiddleware};
13//!
14//! let client = OpenAIClient::from_key("gpt-4o", "sk-...")?;
15//! let middleware = NoopMiddleware;
16//! let wrapped = MiddlewareClient::new(client, middleware);
17//! let response = wrapped.complete(request).await?;
18//! ```
19
20use async_trait::async_trait;
21
22use crate::error::{KernelError, Result};
23use crate::llm::client::LLMClient;
24use crate::llm::types::{LLMRequest, LLMResponse, LLMStream};
25
26/// Hook trait for intercepting [`LLMClient`] request/response cycles.
27///
28/// All methods have default no-op implementations. Override only the hooks
29/// you need. Each hook receives an immutable reference, so middleware cannot
30/// mutate requests or responses — only observe.
31///
32/// The trait is object-safe: `Box<dyn LLMClientMiddleware>` is usable.
33#[async_trait]
34pub trait LLMClientMiddleware: Send + Sync {
35    /// Called before each `complete` request is sent.
36    ///
37    /// Use for logging, metrics, or request tracing.
38    async fn on_request(&self, _request: &LLMRequest) {}
39
40    /// Called after a successful `complete` response.
41    ///
42    /// Use for logging, metrics, or response tracing.
43    async fn on_response(&self, _request: &LLMRequest, _response: &LLMResponse) {}
44
45    /// Called when `complete` returns an error.
46    ///
47    /// Use for error logging, alerting, or metrics.
48    async fn on_error(&self, _request: &LLMRequest, _error: &KernelError) {}
49}
50
51/// A default no-op middleware. Useful as a type parameter default.
52pub struct NoopMiddleware;
53
54#[async_trait]
55impl LLMClientMiddleware for NoopMiddleware {
56    // All methods use default no-op implementations.
57}
58
59/// An [`LLMClient`] wrapper that calls [`LLMClientMiddleware`] hooks around
60/// each request.
61///
62/// Composable: `MiddlewareClient<RetryClient<OpenAIClient>, LogMiddleware>`.
63pub struct MiddlewareClient<C, M> {
64    inner: C,
65    middleware: M,
66}
67
68impl<C, M> MiddlewareClient<C, M> {
69    /// Create a new middleware-wrapped client.
70    pub fn new(inner: C, middleware: M) -> Self {
71        Self { inner, middleware }
72    }
73
74    /// Access the underlying client.
75    pub fn inner(&self) -> &C {
76        &self.inner
77    }
78}
79
80#[async_trait]
81impl<C: LLMClient, M: LLMClientMiddleware> LLMClient for MiddlewareClient<C, M> {
82    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
83        self.middleware.on_request(&request).await;
84        match self.inner.complete(request.clone()).await {
85            Ok(response) => {
86                self.middleware.on_response(&request, &response).await;
87                Ok(response)
88            }
89            Err(err) => {
90                self.middleware.on_error(&request, &err).await;
91                Err(err)
92            }
93        }
94    }
95
96    fn model_name(&self) -> &str {
97        self.inner.model_name()
98    }
99
100    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
101        // Streaming does not invoke middleware hooks — the stream is opaque
102        // and errors arrive asynchronously in the stream events.
103        self.inner.stream_complete(request).await
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::sync::{Arc, Mutex};
111
112    /// A middleware that records hook invocations.
113    #[derive(Default)]
114    struct RecordingMiddleware {
115        on_request_called: Arc<Mutex<bool>>,
116        on_response_called: Arc<Mutex<bool>>,
117        on_error_called: Arc<Mutex<bool>>,
118    }
119
120    #[async_trait]
121    impl LLMClientMiddleware for RecordingMiddleware {
122        async fn on_request(&self, _request: &LLMRequest) {
123            *self.on_request_called.lock().unwrap() = true;
124        }
125
126        async fn on_response(&self, _request: &LLMRequest, _response: &LLMResponse) {
127            *self.on_response_called.lock().unwrap() = true;
128        }
129
130        async fn on_error(&self, _request: &LLMRequest, _error: &KernelError) {
131            *self.on_error_called.lock().unwrap() = true;
132        }
133    }
134
135    /// A mock client that returns a fixed response or error.
136    struct MockClient {
137        response: std::sync::Mutex<Option<Result<LLMResponse>>>,
138    }
139
140    impl MockClient {
141        fn ok() -> Self {
142            Self {
143                response: std::sync::Mutex::new(Some(Ok(LLMResponse {
144                    content: "hello".into(),
145                    model: "mock".into(),
146                    ..Default::default()
147                }))),
148            }
149        }
150
151        fn err() -> Self {
152            Self {
153                response: std::sync::Mutex::new(Some(Err(KernelError::LlmApi("fail".into())))),
154            }
155        }
156    }
157
158    #[async_trait]
159    impl LLMClient for MockClient {
160        async fn complete(&self, _request: LLMRequest) -> Result<LLMResponse> {
161            self.response.lock().unwrap().take().unwrap()
162        }
163
164        fn model_name(&self) -> &str {
165            "mock"
166        }
167
168        async fn stream_complete(&self, _request: LLMRequest) -> Result<LLMStream> {
169            unimplemented!()
170        }
171    }
172
173    #[tokio::test]
174    async fn middleware_calls_on_request_and_on_response_on_success() {
175        let mid = RecordingMiddleware::default();
176        let req_called = mid.on_request_called.clone();
177        let res_called = mid.on_response_called.clone();
178
179        let client = MiddlewareClient::new(MockClient::ok(), mid);
180        let result = client.complete(LLMRequest::builder().build()).await;
181
182        assert!(result.is_ok());
183        assert!(*req_called.lock().unwrap());
184        assert!(*res_called.lock().unwrap());
185    }
186
187    #[tokio::test]
188    async fn middleware_calls_on_error_on_failure() {
189        let mid = RecordingMiddleware::default();
190        let err_called = mid.on_error_called.clone();
191
192        let client = MiddlewareClient::new(MockClient::err(), mid);
193        let result = client.complete(LLMRequest::builder().build()).await;
194
195        assert!(result.is_err());
196        assert!(*err_called.lock().unwrap());
197    }
198
199    #[tokio::test]
200    async fn middleware_delegates_model_name() {
201        let client = MiddlewareClient::new(MockClient::ok(), NoopMiddleware);
202        assert_eq!(client.model_name(), "mock");
203    }
204
205    #[tokio::test]
206    async fn noop_middleware_compiles_and_works() {
207        let client = MiddlewareClient::new(MockClient::ok(), NoopMiddleware);
208        let result = client.complete(LLMRequest::builder().build()).await;
209        assert!(result.is_ok());
210    }
211}