juncture/llm/mock.rs
1//! Mock LLM provider for testing.
2//!
3//! Provides a mock implementation of [`ChatModel`] that returns pre-configured
4//! responses. Useful for testing agent workflows without making actual API calls.
5
6use async_trait::async_trait;
7use futures::stream;
8
9use crate::llm::{
10 BoxStream, CallOptions, ChatModel, LlmError, Message, MessageChunk, ToolCall, ToolCallChunk,
11 ToolDefinition,
12};
13
14/// Mock error for testing error scenarios
15#[derive(Debug, thiserror::Error)]
16#[error("Mock error")]
17struct MockError;
18
19/// Mock LLM provider for testing.
20///
21/// Returns pre-configured responses without making actual API calls.
22/// Useful for unit tests and integration tests.
23///
24/// # Example
25///
26/// ```ignore
27/// use juncture::llm::{ChatModel, MockChatModel};
28/// use juncture::Message;
29///
30/// # #[tokio::main]
31/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
32/// let model = MockChatModel::new("gpt-4")
33/// .with_response("Hello, world!");
34///
35/// let messages = vec![Message::human("Hi")];
36/// let response = model.invoke(&messages, None).await?;
37/// assert!(matches!(response.role, juncture::llm::Role::Ai));
38/// # Ok(())
39/// # }
40/// ```
41#[derive(Clone, Debug)]
42pub struct MockChatModel {
43 /// Model name to report.
44 model_name: String,
45
46 /// Pre-configured text response.
47 response: Option<String>,
48
49 /// Pre-configured tool calls.
50 tool_calls: Vec<ToolCall>,
51
52 /// Available tools.
53 tools: Vec<ToolDefinition>,
54
55 /// Whether to return an error.
56 should_error: bool,
57}
58
59impl MockChatModel {
60 /// Create a new mock model with the given name.
61 ///
62 /// # Example
63 ///
64 /// ```ignore
65 /// use juncture::llm::MockChatModel;
66 ///
67 /// let model = MockChatModel::new("gpt-4");
68 /// assert_eq!(model.model_name(), "gpt-4");
69 /// ```
70 #[must_use]
71 pub fn new(model_name: impl Into<String>) -> Self {
72 Self {
73 model_name: model_name.into(),
74 response: None,
75 tool_calls: Vec::new(),
76 tools: Vec::new(),
77 should_error: false,
78 }
79 }
80
81 /// Set the text response to return.
82 ///
83 /// # Example
84 ///
85 /// ```ignore
86 /// use juncture::llm::MockChatModel;
87 ///
88 /// let model = MockChatModel::new("gpt-4")
89 /// .with_response("Hello, world!");
90 /// ```
91 #[must_use]
92 pub fn with_response(mut self, response: impl Into<String>) -> Self {
93 self.response = Some(response.into());
94 self
95 }
96
97 /// Set the tool calls to return.
98 ///
99 /// # Example
100 ///
101 /// ```ignore
102 /// use juncture::llm::{MockChatModel, ToolCall};
103 /// use serde_json::json;
104 ///
105 /// let tool_calls = vec![
106 /// ToolCall {
107 /// id: "call_123".to_string(),
108 /// name: "get_weather".to_string(),
109 /// arguments: json!({"location": "NYC"}),
110 /// },
111 /// ];
112 /// let model = MockChatModel::new("gpt-4")
113 /// .with_tool_calls(tool_calls);
114 /// ```
115 #[must_use]
116 pub fn with_tool_calls(mut self, calls: Vec<ToolCall>) -> Self {
117 self.tool_calls = calls;
118 self
119 }
120
121 /// Configure the model to return an error on invoke.
122 ///
123 /// # Example
124 ///
125 /// ```ignore
126 /// use juncture::llm::{ChatModel, MockChatModel};
127 /// use juncture::Message;
128 ///
129 /// # #[tokio::main]
130 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
131 /// let model = MockChatModel::new("gpt-4").with_error();
132 /// let messages = vec![Message::human("Hi")];
133 ///
134 /// let result = model.invoke(&messages, None).await;
135 /// assert!(result.is_err());
136 /// # Ok(())
137 /// # }
138 /// ```
139 #[must_use]
140 pub const fn with_error(mut self) -> Self {
141 self.should_error = true;
142 self
143 }
144}
145
146impl Default for MockChatModel {
147 fn default() -> Self {
148 Self::new("mock-model")
149 }
150}
151
152#[cfg_attr(target_family = "wasm", async_trait(?Send))]
153#[cfg_attr(not(target_family = "wasm"), async_trait)]
154impl ChatModel for MockChatModel {
155 async fn invoke(
156 &self,
157 _messages: &[Message],
158 _options: Option<&CallOptions>,
159 ) -> Result<Message, LlmError> {
160 if self.should_error {
161 return Err(LlmError::Other(Box::new(MockError)));
162 }
163
164 let content = self.response.clone().unwrap_or_default();
165
166 let msg = Message::ai_with_tool_calls(content, self.tool_calls.clone());
167
168 Ok(msg)
169 }
170
171 fn stream(
172 &self,
173 _messages: &[Message],
174 _options: Option<&CallOptions>,
175 ) -> BoxStream<'_, Result<MessageChunk, LlmError>> {
176 if self.should_error {
177 let error = LlmError::Other(Box::new(MockError));
178 return Box::pin(stream::once(async move { Err(error) }));
179 }
180
181 let content = self.response.clone().unwrap_or_default();
182 let chunk = MessageChunk {
183 content,
184 tool_call_chunks: self
185 .tool_calls
186 .iter()
187 .enumerate()
188 .map(|(index, call)| ToolCallChunk {
189 id: Some(call.id.clone()),
190 name: Some(call.name.clone()),
191 args_delta: call.arguments.to_string(),
192 index,
193 })
194 .collect(),
195 usage_delta: None,
196 };
197
198 Box::pin(stream::once(async move { Ok(chunk) }))
199 }
200
201 fn bind_tools(&self, tools: Vec<ToolDefinition>) -> Self {
202 let mut new_model = self.clone();
203 new_model.tools = tools;
204 new_model
205 }
206
207 fn model_name(&self) -> &str {
208 &self.model_name
209 }
210}
211
212// Rust guideline compliant 2026-05-19