use super::{Backend, LlmRequest, LlmResponse};
use crate::error::Result;
use async_trait::async_trait;
use reqwest::Client;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone)]
pub struct RecordedCall {
pub request: LlmRequest,
pub response_text: String,
pub status: u16,
}
pub struct RecordingBackend {
inner: Arc<dyn Backend>,
calls: Mutex<Vec<RecordedCall>>,
}
impl RecordingBackend {
pub fn wrap(inner: Arc<dyn Backend>) -> Self {
Self {
inner,
calls: Mutex::new(Vec::new()),
}
}
pub fn calls(&self) -> Vec<RecordedCall> {
self.calls
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub fn call_count(&self) -> usize {
self.calls
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.len()
}
pub fn clear(&self) {
self.calls
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clear();
}
}
impl std::fmt::Debug for RecordingBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RecordingBackend")
.field("inner", &self.inner.name())
.field("call_count", &self.call_count())
.finish()
}
}
#[async_trait]
impl Backend for RecordingBackend {
async fn complete(
&self,
client: &Client,
base_url: &str,
request: &LlmRequest,
) -> Result<LlmResponse> {
let response = self.inner.complete(client, base_url, request).await?;
self.calls
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(RecordedCall {
request: request.clone(),
response_text: response.text.clone(),
status: response.status,
});
Ok(response)
}
async fn complete_streaming(
&self,
client: &Client,
base_url: &str,
request: &LlmRequest,
on_token: &mut (dyn FnMut(String) + Send),
) -> Result<LlmResponse> {
let response = self
.inner
.complete_streaming(client, base_url, request, on_token)
.await?;
self.calls
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(RecordedCall {
request: request.clone(),
response_text: response.text.clone(),
status: response.status,
});
Ok(response)
}
fn name(&self) -> &'static str {
"recording"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::MockBackend;
#[tokio::test]
async fn test_recording_backend_captures_calls() {
let mock = Arc::new(MockBackend::fixed("hello"));
let recording = RecordingBackend::wrap(mock);
let client = Client::new();
let request = LlmRequest {
model: "test".to_string(),
system_prompt: None,
prompt: "say hi".to_string(),
messages: vec![],
config: Default::default(),
stream: false,
request_timeout: None,
};
let resp = recording
.complete(&client, "http://unused", &request)
.await
.unwrap();
assert_eq!(resp.text, "hello");
let calls = recording.calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].request.prompt, "say hi");
assert_eq!(calls[0].response_text, "hello");
assert_eq!(calls[0].status, 200);
}
#[tokio::test]
async fn test_recording_backend_clear() {
let mock = Arc::new(MockBackend::fixed("test"));
let recording = RecordingBackend::wrap(mock);
let client = Client::new();
let request = LlmRequest {
model: "test".to_string(),
system_prompt: None,
prompt: "test".to_string(),
messages: vec![],
config: Default::default(),
stream: false,
request_timeout: None,
};
recording
.complete(&client, "http://unused", &request)
.await
.unwrap();
assert_eq!(recording.call_count(), 1);
recording.clear();
assert_eq!(recording.call_count(), 0);
}
#[tokio::test]
async fn test_recording_backend_streaming() {
let mock = Arc::new(MockBackend::fixed("streamed"));
let recording = RecordingBackend::wrap(mock);
let client = Client::new();
let request = LlmRequest {
model: "test".to_string(),
system_prompt: None,
prompt: "test".to_string(),
messages: vec![],
config: Default::default(),
stream: true,
request_timeout: None,
};
let mut tokens = Vec::new();
recording
.complete_streaming(&client, "http://unused", &request, &mut |t| {
tokens.push(t);
})
.await
.unwrap();
assert_eq!(tokens, vec!["streamed"]);
assert_eq!(recording.call_count(), 1);
}
}