Skip to main content

lc_testkit/
recording.rs

1//! `RecordingProvider`: makes one real call, appending the request/response pair to the JSONL recording file.
2//!
3//! Recording is **pass-through**: a failed real call returns the failure without writing;
4//! a successful call whose write fails only `log::warn!`s and does not block the real result.
5
6use std::io::Write;
7use std::path::Path;
8use std::pin::Pin;
9use std::sync::{Arc, Mutex};
10
11use async_trait::async_trait;
12use futures_util::{Stream, StreamExt};
13use lc_core::language_models::{
14    BaseChatModel, BaseLanguageModel, LLMResult, StreamChunk, TokenUsage,
15};
16use lc_core::runnables::{Runnable, RunnableConfig};
17use lc_core::tools::ToolDefinition;
18use lc_providers::ProviderError;
19use lc_schema::Message;
20use serde::{Deserialize, Serialize};
21
22use crate::error::TestkitError;
23
24/// One recorded request/response pair, serialized as a single JSONL line.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct RecordedExchange {
27    /// The request (including system/user/assistant/tool history).
28    pub messages: Vec<Message>,
29    /// The full response.
30    pub response: LLMResult,
31    /// Tool definitions bound on the request side (non-empty after `bind_tools`).
32    ///
33    /// `#[serde(default)]` lets old fixtures (without a `tools` field) read as `None`, compatible
34    /// with zero changes; `skip_serializing_if` keeps recordings without bound tools in the old format.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub tools: Option<Vec<ToolDefinition>>,
37}
38
39/// Shared handle for append-mode writing to the recording file (protected by a std Mutex).
40pub struct Recorder {
41    file: Mutex<std::fs::File>,
42}
43
44impl Recorder {
45    /// Opens/creates the recording file. If it cannot be opened, construction fails fast with `Err`.
46    pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
47        let file = std::fs::OpenOptions::new()
48            .create(true)
49            .append(true)
50            .open(path)?;
51        Ok(Self {
52            file: Mutex::new(file),
53        })
54    }
55
56    /// Best-effort append of one recording: failure only `log::warn!`s and never propagates up.
57    pub fn record(&self, exchange: &RecordedExchange) {
58        let line = match serde_json::to_string(exchange) {
59            Ok(line) => line,
60            Err(e) => {
61                log::warn!("lc-testkit: failed to serialize recording: {e}");
62                return;
63            }
64        };
65        let Ok(mut file) = self.file.lock() else {
66            log::warn!("lc-testkit: recording lock poisoned");
67            return;
68        };
69        if let Err(e) = writeln!(file, "{line}") {
70            log::warn!("lc-testkit: failed to append recording: {e}");
71        }
72    }
73}
74
75/// Maps an inner model error into `TestkitError` (losslessly passed through `ProviderError`).
76fn to_testkit<E: Into<ProviderError>>(e: E) -> TestkitError {
77    TestkitError::Inner(e.into())
78}
79
80/// Wraps any `BaseChatModel`: after a successful response, appends the request/response pair to JSONL.
81pub struct RecordingProvider<M> {
82    inner: M,
83    recorder: Arc<Recorder>,
84    model_name: String,
85    /// Currently bound tool definitions (set by `bind_tools`, recorded into the exchange on chat).
86    tools: Option<Vec<ToolDefinition>>,
87}
88
89impl<M> RecordingProvider<M>
90where
91    M: BaseChatModel + Send + Sync + 'static,
92    M::Error: Into<ProviderError>,
93{
94    /// Constructs from an inner model + recording file. An unopenable file → `Err`.
95    pub fn new(inner: M, path: impl AsRef<Path>) -> std::io::Result<Self> {
96        let model_name = format!("{}-recorded", inner.model_name());
97        let recorder = Arc::new(Recorder::new(path)?);
98        Ok(Self {
99            inner,
100            recorder,
101            model_name,
102            tools: None,
103        })
104    }
105
106    /// Accesses the inner model.
107    pub fn inner(&self) -> &M {
108        &self.inner
109    }
110
111    /// Binds tools: returns a new instance that records that tool set.
112    ///
113    /// Later `chat` calls record the tool definitions into `RecordedExchange.tools`, letting the
114    /// replay side route by tool name (see [`crate::ReplayStrategy::ByToolName`]).
115    pub fn bind_tools(&self, tools: Vec<ToolDefinition>) -> Self
116    where
117        M: Clone,
118    {
119        Self {
120            inner: self.inner.clone(),
121            recorder: self.recorder.clone(),
122            model_name: self.model_name.clone(),
123            tools: Some(tools),
124        }
125    }
126}
127
128#[async_trait]
129impl<M> Runnable<Vec<Message>, LLMResult> for RecordingProvider<M>
130where
131    M: BaseChatModel + Clone + Send + Sync + 'static,
132    M::Error: Into<ProviderError>,
133{
134    type Error = TestkitError;
135
136    async fn invoke(
137        &self,
138        input: Vec<Message>,
139        config: Option<RunnableConfig>,
140    ) -> Result<LLMResult, Self::Error> {
141        self.chat(input, config).await
142    }
143}
144
145impl<M> BaseLanguageModel<Vec<Message>, LLMResult> for RecordingProvider<M>
146where
147    M: BaseChatModel + Clone + Send + Sync + 'static,
148    M::Error: Into<ProviderError>,
149{
150    fn model_name(&self) -> &str {
151        &self.model_name
152    }
153
154    fn get_num_tokens(&self, text: &str) -> usize {
155        self.inner.get_num_tokens(text)
156    }
157
158    fn temperature(&self) -> Option<f32> {
159        self.inner.temperature()
160    }
161
162    fn max_tokens(&self) -> Option<usize> {
163        self.inner.max_tokens()
164    }
165
166    fn with_temperature(mut self, temp: f32) -> Self {
167        self.inner = self.inner.with_temperature(temp);
168        self
169    }
170
171    fn with_max_tokens(mut self, max: usize) -> Self {
172        self.inner = self.inner.with_max_tokens(max);
173        self
174    }
175}
176
177#[async_trait]
178impl<M> BaseChatModel for RecordingProvider<M>
179where
180    M: BaseChatModel + Clone + Send + Sync + 'static,
181    M::Error: Into<ProviderError>,
182{
183    async fn chat(
184        &self,
185        messages: Vec<Message>,
186        config: Option<RunnableConfig>,
187    ) -> Result<LLMResult, Self::Error> {
188        let response = self
189            .inner
190            .chat(messages.clone(), config)
191            .await
192            .map_err(to_testkit)?;
193        self.recorder.record(&RecordedExchange {
194            messages,
195            response: response.clone(),
196            tools: self.tools.clone(),
197        });
198        Ok(response)
199    }
200
201    async fn stream_chat(
202        &self,
203        messages: Vec<Message>,
204        config: Option<RunnableConfig>,
205    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
206    {
207        let mut stream = self
208            .inner
209            .stream_chat(messages.clone(), config)
210            .await
211            .map_err(to_testkit)?;
212        let mut full = String::new();
213        let mut usage: Option<TokenUsage> = None;
214        while let Some(chunk) = stream.next().await {
215            let chunk = chunk.map_err(to_testkit)?;
216            full.push_str(&chunk.text);
217            if chunk.token_usage.is_some() {
218                usage = chunk.token_usage;
219            }
220        }
221        let response = LLMResult {
222            content: full.clone(),
223            model: self.model_name.clone(),
224            token_usage: usage.clone(),
225            ..Default::default()
226        };
227        self.recorder.record(&RecordedExchange {
228            messages,
229            response,
230            tools: self.tools.clone(),
231        });
232        let stream = futures_util::stream::iter(vec![Ok(StreamChunk {
233            text: full,
234            token_usage: usage,
235        })]);
236        Ok(Box::pin(stream))
237    }
238
239    fn bind_tools(
240        &self,
241        tools: Vec<ToolDefinition>,
242    ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
243        // Delegate to the inherent `bind_tools`: clone the inner model, record the tool set, return a new instance.
244        Some(Box::new(self.bind_tools(tools)))
245    }
246}