Skip to main content

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