1#![allow(clippy::unwrap_used, clippy::expect_used)]
9
10use std::sync::Mutex;
11
12use async_trait::async_trait;
13
14use super::StructuredRequest;
15use super::{ChatProvider, Completion, StreamSender, ToolSpec};
16use crate::error::{Error, Result};
17use crate::message::Message;
18use tracing::Instrument;
19
20#[derive(Default)]
21pub struct MockProvider {
22 scripted: Mutex<Vec<Completion>>,
23 errors: Mutex<Vec<Error>>,
27 structured_responses: Mutex<Vec<Result<serde_json::Value>>>,
29 calls: Mutex<Vec<Vec<Message>>>,
30 on_complete: Option<std::sync::Arc<tokio::sync::Notify>>,
34 on_complete_fn: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
38}
39
40impl MockProvider {
41 pub fn with_usage(scripted: Vec<Completion>) -> Self {
44 Self::new(scripted)
45 }
46
47 pub fn new(scripted: Vec<Completion>) -> Self {
49 Self {
50 scripted: Mutex::new(scripted),
51 calls: Mutex::new(Vec::new()),
52 errors: Mutex::new(Vec::new()),
53 structured_responses: Mutex::new(Vec::new()),
54 on_complete: None,
55 on_complete_fn: None,
56 }
57 }
58
59 pub fn with_on_complete(mut self, notify: std::sync::Arc<tokio::sync::Notify>) -> Self {
64 self.on_complete = Some(notify);
65 self
66 }
67
68 pub fn with_on_complete_fn<F>(mut self, hook: F) -> Self
71 where
72 F: Fn() + Send + Sync + 'static,
73 {
74 self.on_complete_fn = Some(std::sync::Arc::new(hook));
75 self
76 }
77
78 pub fn with_errors(self, errors: Vec<Error>) -> Self {
82 *self.errors.lock().unwrap() = errors;
83 self
84 }
85
86 pub fn calls(&self) -> Vec<Vec<Message>> {
88 self.calls.lock().unwrap().clone()
89 }
90
91 pub fn with_structured_responses(self, responses: Vec<Result<serde_json::Value>>) -> Self {
95 *self.structured_responses.lock().unwrap() = responses;
96 self
97 }
98}
99
100#[async_trait]
101impl ChatProvider for MockProvider {
102 async fn complete(&self, messages: &[Message], _tools: &[ToolSpec]) -> Result<Completion> {
103 let span = tracing::info_span!("llm.complete", provider = "mock", model = "mock");
104 async move {
105 tracing::info!("mock llm call");
107 self.calls.lock().unwrap().push(messages.to_vec());
108 {
110 let mut errors = self.errors.lock().unwrap();
111 if !errors.is_empty() {
112 let err = errors.remove(0);
113 return Err(err);
114 }
115 }
116 let mut queue = self.scripted.lock().unwrap();
117 if queue.is_empty() {
118 return Err(Error::Llm {
119 provider: "mock".into(),
120 message: "MockProvider: no scripted completions left".into(),
121 });
122 }
123 let completion = queue.remove(0);
124 drop(queue);
125 if let Some(ref hook) = self.on_complete_fn {
126 hook();
127 }
128 if let Some(ref notify) = self.on_complete {
129 notify.notify_one();
130 }
131 Ok(completion)
132 }
133 .instrument(span)
134 .await
135 }
136
137 async fn complete_structured(&self, _req: StructuredRequest) -> Result<serde_json::Value> {
138 let mut queue = self.structured_responses.lock().unwrap();
139 if queue.is_empty() {
140 return Err(Error::Config {
142 message: "MockProvider: no structured responses configured".into(),
143 });
144 }
145 queue.remove(0)
146 }
147
148 async fn stream(
149 &self,
150 messages: &[Message],
151 tools: &[ToolSpec],
152 stream_tx: Option<StreamSender>,
153 _cancel_token: Option<tokio_util::sync::CancellationToken>,
154 ) -> Result<Completion> {
155 let completion = self.complete(messages, tools).await?;
157 if let Some(tx) = stream_tx {
158 if let Some(reasoning) = &completion.reasoning_content {
159 if !reasoning.is_empty() {
160 let _ = tx.send(super::StreamChunk::Reasoning(reasoning.clone()));
161 }
162 }
163 if !completion.content.is_empty() {
164 let _ = tx.send(super::StreamChunk::Text(completion.content.clone()));
165 }
166 }
167 Ok(completion)
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[tokio::test]
176 async fn returns_scripted_in_order_and_records_calls() {
177 let provider = MockProvider::new(vec![
178 Completion {
179 content: "one".into(),
180 tool_calls: vec![],
181 finish_reason: Some("stop".into()),
182 usage: None,
183 reasoning_content: None,
184 },
185 Completion {
186 content: "two".into(),
187 tool_calls: vec![],
188 finish_reason: Some("stop".into()),
189 usage: None,
190 reasoning_content: None,
191 },
192 ]);
193
194 let m1 = vec![Message::user("hi")];
195 let r1 = provider.complete(&m1, &[]).await.unwrap();
196 assert_eq!(r1.content, "one");
197
198 let m2 = vec![Message::user("bye")];
199 let r2 = provider.complete(&m2, &[]).await.unwrap();
200 assert_eq!(r2.content, "two");
201
202 assert_eq!(provider.calls().len(), 2);
203 assert_eq!(provider.calls()[0][0].content, "hi");
204 }
205
206 #[tokio::test]
207 async fn errors_when_queue_drained() {
208 let provider = MockProvider::new(vec![]);
209 let err = provider.complete(&[], &[]).await.unwrap_err();
210 assert!(matches!(err, Error::Llm { .. }));
211 }
212}
213
214#[cfg(test)]
215mod tracing_tests {
216 use super::*;
217 use crate::llm::TokenUsage;
218 use tracing_test::traced_test;
219
220 #[tokio::test]
221 async fn errors_are_injected_before_scripted_completions() {
222 use crate::error::Error;
224 let provider = MockProvider::new(vec![Completion {
225 content: "fallback".into(),
226 tool_calls: vec![],
227 finish_reason: Some("stop".into()),
228 usage: None,
229 reasoning_content: None,
230 }])
231 .with_errors(vec![Error::Llm {
232 provider: "mock".into(),
233 message: "injected error".into(),
234 }]);
235
236 let r1 = provider.complete(&[], &[]).await;
238 assert!(r1.is_err(), "first call must return injected error");
239 assert!(r1.unwrap_err().to_string().contains("injected error"));
240
241 let r2 = provider.complete(&[], &[]).await;
243 assert!(r2.is_ok(), "second call must return scripted completion");
244 assert_eq!(r2.unwrap().content, "fallback");
245 }
246
247 #[tokio::test]
248 async fn complete_structured_returns_scripted_value() {
249 use crate::llm::StructuredRequest;
251 use crate::message::Message;
252 let provider = MockProvider::new(vec![])
253 .with_structured_responses(vec![Ok(serde_json::json!({"key": "val"}))]);
254 let req = StructuredRequest {
255 messages: vec![Message::user("q")],
256 schema: serde_json::json!({}),
257 schema_name: "test".into(),
258 };
259 let result = provider.complete_structured(req).await.unwrap();
260 assert_eq!(
261 result["key"], "val",
262 "must return the scripted structured value"
263 );
264 }
265
266 #[tokio::test]
267 async fn complete_structured_empty_queue_returns_error() {
268 use crate::llm::StructuredRequest;
270 use crate::message::Message;
271 let provider = MockProvider::new(vec![]);
272 let req = StructuredRequest {
274 messages: vec![Message::user("q")],
275 schema: serde_json::json!({}),
276 schema_name: "test".into(),
277 };
278 let result = provider.complete_structured(req).await;
279 assert!(result.is_err(), "empty structured queue must return error");
280 }
281
282 #[tokio::test]
283 async fn stream_sends_content_to_channel() {
284 use crate::llm::StreamChunk;
286 let provider = MockProvider::new(vec![Completion {
287 content: "streamed text".into(),
288 tool_calls: vec![],
289 finish_reason: Some("stop".into()),
290 usage: None,
291 reasoning_content: None,
292 }]);
293 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
294 provider.stream(&[], &[], Some(tx), None).await.unwrap();
295 let chunk = rx.try_recv().expect("must receive a chunk");
296 assert!(
297 matches!(chunk, StreamChunk::Text(t) if t == "streamed text"),
298 "must receive Text chunk with completion content"
299 );
300 }
301
302 #[tokio::test]
303 async fn stream_sends_reasoning_before_text() {
304 use crate::llm::StreamChunk;
306 let provider = MockProvider::new(vec![Completion {
307 content: "answer".into(),
308 tool_calls: vec![],
309 finish_reason: Some("stop".into()),
310 usage: None,
311 reasoning_content: Some("thinking step".into()),
312 }]);
313 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
314 provider.stream(&[], &[], Some(tx), None).await.unwrap();
315
316 let first = rx.try_recv().expect("must have first chunk");
317 let second = rx.try_recv().expect("must have second chunk");
318 assert!(
319 matches!(first, StreamChunk::Reasoning(r) if r == "thinking step"),
320 "first chunk must be Reasoning"
321 );
322 assert!(
323 matches!(second, StreamChunk::Text(t) if t == "answer"),
324 "second chunk must be Text"
325 );
326 }
327
328 #[tokio::test]
329 async fn calls_records_all_messages() {
330 let provider = MockProvider::new(vec![
332 Completion {
333 content: "a".into(),
334 tool_calls: vec![],
335 finish_reason: None,
336 usage: None,
337 reasoning_content: None,
338 },
339 Completion {
340 content: "b".into(),
341 tool_calls: vec![],
342 finish_reason: None,
343 usage: None,
344 reasoning_content: None,
345 },
346 ]);
347 let msgs1 = vec![Message::user("first call")];
348 let msgs2 = vec![Message::user("second call")];
349 provider.complete(&msgs1, &[]).await.unwrap();
350 provider.complete(&msgs2, &[]).await.unwrap();
351
352 let calls = provider.calls();
353 assert_eq!(calls.len(), 2, "must record 2 calls");
354 assert_eq!(calls[0][0].content, "first call");
355 assert_eq!(calls[1][0].content, "second call");
356 }
357
358 #[traced_test]
359 #[tokio::test]
360 async fn llm_complete_records_token_fields() {
361 let usage = TokenUsage {
362 reasoning_tokens: 0,
363 prompt_tokens: 100,
364 completion_tokens: 50,
365 total_tokens: 150,
366 cache_hit_tokens: 0,
367 cache_miss_tokens: 0,
368 };
370 let provider = MockProvider::new(vec![Completion {
371 content: "response".into(),
372 tool_calls: vec![],
373 finish_reason: Some("stop".into()),
374 usage: Some(usage),
375 reasoning_content: None,
376 }]);
377
378 provider.complete(&[], &[]).await.unwrap();
379
380 assert!(logs_contain("llm.complete"));
382 }
383}