pytest-mockllm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ """
2
+ pytest-mockllm: Zero-config LLM API mocking for pytest.
3
+
4
+ The ultimate pytest plugin for testing LLM-powered applications without
5
+ hitting real APIs, spending money, or dealing with non-deterministic responses.
6
+
7
+ Quick Start:
8
+ ```python
9
+ def test_my_chatbot(mock_openai):
10
+ mock_openai.add_response("Hello! I'm here to help.")
11
+
12
+ result = my_chatbot.chat("Hi!")
13
+ assert "help" in result.lower()
14
+ ```
15
+
16
+ For more examples and documentation, visit:
17
+ https://github.com/pytest-mockllm/pytest-mockllm
18
+ """
19
+
20
+ from pytest_mockllm.core import MockLLM, MockResponse
21
+ from pytest_mockllm.providers.openai import OpenAIMock
22
+ from pytest_mockllm.providers.anthropic import AnthropicMock
23
+ from pytest_mockllm.providers.gemini import GeminiMock
24
+ from pytest_mockllm.recording import LLMRecorder
25
+
26
+ __version__ = "0.1.0"
27
+ __all__ = [
28
+ # Core
29
+ "MockLLM",
30
+ "MockResponse",
31
+ # Provider mocks
32
+ "OpenAIMock",
33
+ "AnthropicMock",
34
+ "GeminiMock",
35
+ # Recording
36
+ "LLMRecorder",
37
+ # Version
38
+ "__version__",
39
+ ]
pytest_mockllm/core.py ADDED
@@ -0,0 +1,358 @@
1
+ """
2
+ Core mock classes and response types for pytest-mockllm.
3
+
4
+ This module contains the foundational classes that all provider-specific
5
+ mocks inherit from, ensuring a consistent API across providers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import uuid
12
+ from abc import ABC, abstractmethod
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Iterator, Optional, Union
15
+ from unittest.mock import MagicMock, patch
16
+
17
+
18
+ @dataclass
19
+ class TokenUsage:
20
+ """Token usage statistics for a mock response."""
21
+
22
+ prompt_tokens: int = 0
23
+ completion_tokens: int = 0
24
+ total_tokens: int = 0
25
+
26
+ def __post_init__(self) -> None:
27
+ if self.total_tokens == 0:
28
+ self.total_tokens = self.prompt_tokens + self.completion_tokens
29
+
30
+
31
+ @dataclass
32
+ class MockResponse:
33
+ """
34
+ A configurable mock response for LLM APIs.
35
+
36
+ This provides a provider-agnostic way to define responses that
37
+ will be converted to the appropriate format for each provider.
38
+
39
+ Examples:
40
+ >>> response = MockResponse(content="Hello, world!")
41
+ >>> response = MockResponse(
42
+ ... content="Let me help with that.",
43
+ ... token_usage=TokenUsage(prompt_tokens=10, completion_tokens=20),
44
+ ... latency_ms=100,
45
+ ... )
46
+ """
47
+
48
+ content: str
49
+ role: str = "assistant"
50
+ model: str = "mock-model"
51
+ token_usage: Optional[TokenUsage] = None
52
+ finish_reason: str = "stop"
53
+ latency_ms: int = 0
54
+ id: str = field(default_factory=lambda: f"mock-{uuid.uuid4().hex[:8]}")
55
+
56
+ # For streaming responses
57
+ stream_chunks: Optional[list[str]] = None
58
+
59
+ # For function/tool calling
60
+ tool_calls: Optional[list[dict[str, Any]]] = None
61
+ function_call: Optional[dict[str, Any]] = None
62
+
63
+ def __post_init__(self) -> None:
64
+ if self.token_usage is None:
65
+ # Estimate tokens (rough approximation: ~4 chars per token)
66
+ estimated_tokens = len(self.content) // 4 + 1
67
+ self.token_usage = TokenUsage(
68
+ prompt_tokens=10, # Assume minimal prompt
69
+ completion_tokens=estimated_tokens,
70
+ )
71
+
72
+
73
+ @dataclass
74
+ class MockError:
75
+ """Configuration for simulating API errors."""
76
+
77
+ error_type: str # "rate_limit", "timeout", "auth", "server", "invalid_request"
78
+ message: str = ""
79
+ after_calls: int = 0 # Trigger after N successful calls (0 = immediate)
80
+
81
+ def __post_init__(self) -> None:
82
+ error_messages = {
83
+ "rate_limit": "Rate limit exceeded. Please retry after 60 seconds.",
84
+ "timeout": "Request timed out.",
85
+ "auth": "Invalid API key provided.",
86
+ "server": "Internal server error.",
87
+ "invalid_request": "Invalid request parameters.",
88
+ }
89
+ if not self.message:
90
+ self.message = error_messages.get(self.error_type, "Unknown error")
91
+
92
+
93
+ class MockLLM(ABC):
94
+ """
95
+ Abstract base class for LLM mocks.
96
+
97
+ All provider-specific mocks (OpenAI, Anthropic, etc.) inherit from this
98
+ class to ensure a consistent API.
99
+
100
+ Features:
101
+ - Response queue management
102
+ - Call history tracking
103
+ - Token usage accumulation
104
+ - Error simulation
105
+ - Latency simulation
106
+ """
107
+
108
+ def __init__(self) -> None:
109
+ self._responses: list[MockResponse] = []
110
+ self._response_index: int = 0
111
+ self._calls: list[dict[str, Any]] = []
112
+ self._patches: list[Any] = []
113
+ self._error: Optional[MockError] = None
114
+ self._call_count: int = 0
115
+ self._total_tokens: int = 0
116
+ self._total_prompt_tokens: int = 0
117
+ self._total_completion_tokens: int = 0
118
+ self._default_response: Optional[MockResponse] = None
119
+ self._strict_mode: bool = False
120
+
121
+ @property
122
+ def calls(self) -> list[dict[str, Any]]:
123
+ """All recorded API calls made to this mock."""
124
+ return self._calls
125
+
126
+ @property
127
+ def call_count(self) -> int:
128
+ """Number of API calls made to this mock."""
129
+ return self._call_count
130
+
131
+ @property
132
+ def last_call(self) -> Optional[dict[str, Any]]:
133
+ """The most recent API call, or None if no calls made."""
134
+ return self._calls[-1] if self._calls else None
135
+
136
+ @property
137
+ def total_tokens(self) -> int:
138
+ """Total tokens used across all calls."""
139
+ return self._total_tokens
140
+
141
+ @property
142
+ def total_prompt_tokens(self) -> int:
143
+ """Total prompt tokens used across all calls."""
144
+ return self._total_prompt_tokens
145
+
146
+ @property
147
+ def total_completion_tokens(self) -> int:
148
+ """Total completion tokens used across all calls."""
149
+ return self._total_completion_tokens
150
+
151
+ def add_response(
152
+ self,
153
+ content: str,
154
+ *,
155
+ model: str = "mock-model",
156
+ token_usage: Optional[TokenUsage] = None,
157
+ latency_ms: int = 0,
158
+ tool_calls: Optional[list[dict[str, Any]]] = None,
159
+ stream_chunks: Optional[list[str]] = None,
160
+ ) -> "MockLLM":
161
+ """
162
+ Add a response to the queue.
163
+
164
+ Responses are returned in order. When exhausted, returns default response
165
+ or raises an error in strict mode.
166
+
167
+ Args:
168
+ content: The response content/message
169
+ model: Model name to include in response
170
+ token_usage: Custom token usage stats
171
+ latency_ms: Simulated latency in milliseconds
172
+ tool_calls: Tool/function calls to include
173
+ stream_chunks: For streaming, custom chunk splits
174
+
175
+ Returns:
176
+ self for method chaining
177
+
178
+ Example:
179
+ >>> mock.add_response("First response").add_response("Second response")
180
+ """
181
+ response = MockResponse(
182
+ content=content,
183
+ model=model,
184
+ token_usage=token_usage,
185
+ latency_ms=latency_ms,
186
+ tool_calls=tool_calls,
187
+ stream_chunks=stream_chunks,
188
+ )
189
+ self._responses.append(response)
190
+ return self
191
+
192
+ def add_responses(self, *contents: str) -> "MockLLM":
193
+ """
194
+ Add multiple simple text responses at once.
195
+
196
+ Example:
197
+ >>> mock.add_responses("Hello!", "How can I help?", "Goodbye!")
198
+ """
199
+ for content in contents:
200
+ self.add_response(content)
201
+ return self
202
+
203
+ def set_default_response(self, content: str, **kwargs: Any) -> "MockLLM":
204
+ """
205
+ Set a default response when the queue is exhausted.
206
+
207
+ This response will be returned for all calls after the queue is empty,
208
+ unless strict mode is enabled.
209
+ """
210
+ self._default_response = MockResponse(content=content, **kwargs)
211
+ return self
212
+
213
+ def simulate_error(
214
+ self,
215
+ error_type: str,
216
+ *,
217
+ message: str = "",
218
+ after_calls: int = 0,
219
+ ) -> "MockLLM":
220
+ """
221
+ Configure the mock to simulate an API error.
222
+
223
+ Args:
224
+ error_type: One of "rate_limit", "timeout", "auth", "server", "invalid_request"
225
+ message: Custom error message (uses default if not provided)
226
+ after_calls: Number of successful calls before error (0 = immediate)
227
+
228
+ Example:
229
+ >>> mock.simulate_error("rate_limit", after_calls=5)
230
+ """
231
+ self._error = MockError(
232
+ error_type=error_type,
233
+ message=message,
234
+ after_calls=after_calls,
235
+ )
236
+ return self
237
+
238
+ def set_strict_mode(self, enabled: bool = True) -> "MockLLM":
239
+ """
240
+ Enable strict mode - raises error if no response configured.
241
+
242
+ In strict mode, tests fail immediately if an unconfigured LLM call is made,
243
+ helping catch missing mock configurations.
244
+ """
245
+ self._strict_mode = enabled
246
+ return self
247
+
248
+ def _get_next_response(self) -> MockResponse:
249
+ """Get the next response from the queue."""
250
+ # Check for error simulation
251
+ if self._error and self._call_count >= self._error.after_calls:
252
+ self._raise_provider_error(self._error)
253
+
254
+ # Try to get from queue
255
+ if self._response_index < len(self._responses):
256
+ response = self._responses[self._response_index]
257
+ self._response_index += 1
258
+ elif self._default_response:
259
+ response = self._default_response
260
+ elif self._strict_mode:
261
+ raise RuntimeError(
262
+ f"No mock response configured for call #{self._call_count + 1}. "
263
+ "Use mock.add_response() or mock.set_default_response() to configure responses, "
264
+ "or disable strict mode with mock.set_strict_mode(False)."
265
+ )
266
+ else:
267
+ # Fallback default
268
+ response = MockResponse(content="Mock response from pytest-mockllm")
269
+
270
+ # Simulate latency
271
+ if response.latency_ms > 0:
272
+ time.sleep(response.latency_ms / 1000.0)
273
+
274
+ # Track usage
275
+ if response.token_usage:
276
+ self._total_tokens += response.token_usage.total_tokens
277
+ self._total_prompt_tokens += response.token_usage.prompt_tokens
278
+ self._total_completion_tokens += response.token_usage.completion_tokens
279
+
280
+ self._call_count += 1
281
+ return response
282
+
283
+ def _record_call(self, **kwargs: Any) -> None:
284
+ """Record an API call for later inspection."""
285
+ self._calls.append({
286
+ "call_number": self._call_count + 1,
287
+ "timestamp": time.time(),
288
+ **kwargs,
289
+ })
290
+
291
+ @abstractmethod
292
+ def _raise_provider_error(self, error: MockError) -> None:
293
+ """Raise a provider-specific error. Must be implemented by subclasses."""
294
+ pass
295
+
296
+ @abstractmethod
297
+ def __enter__(self) -> "MockLLM":
298
+ """Start mocking. Must be implemented by subclasses."""
299
+ pass
300
+
301
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
302
+ """Stop mocking and restore original behavior."""
303
+ for p in reversed(self._patches):
304
+ p.stop()
305
+ self._patches.clear()
306
+
307
+ def reset(self) -> None:
308
+ """Reset the mock state (calls, responses, counters)."""
309
+ self._responses.clear()
310
+ self._response_index = 0
311
+ self._calls.clear()
312
+ self._call_count = 0
313
+ self._total_tokens = 0
314
+ self._total_prompt_tokens = 0
315
+ self._total_completion_tokens = 0
316
+ self._error = None
317
+
318
+
319
+ # Cost estimation data (USD per 1K tokens, as of Dec 2024)
320
+ MODEL_COSTS: dict[str, dict[str, float]] = {
321
+ # OpenAI
322
+ "gpt-4o": {"input": 0.0025, "output": 0.01},
323
+ "gpt-4o-mini": {"input": 0.00015, "output": 0.0006},
324
+ "gpt-4-turbo": {"input": 0.01, "output": 0.03},
325
+ "gpt-4": {"input": 0.03, "output": 0.06},
326
+ "gpt-3.5-turbo": {"input": 0.0005, "output": 0.0015},
327
+ # Anthropic
328
+ "claude-3-5-sonnet-20241022": {"input": 0.003, "output": 0.015},
329
+ "claude-3-5-haiku-20241022": {"input": 0.001, "output": 0.005},
330
+ "claude-3-opus-20240229": {"input": 0.015, "output": 0.075},
331
+ # Google
332
+ "gemini-1.5-pro": {"input": 0.00125, "output": 0.005},
333
+ "gemini-1.5-flash": {"input": 0.000075, "output": 0.0003},
334
+ # Default fallback
335
+ "default": {"input": 0.001, "output": 0.002},
336
+ }
337
+
338
+
339
+ def estimate_cost(
340
+ model: str,
341
+ prompt_tokens: int,
342
+ completion_tokens: int,
343
+ ) -> float:
344
+ """
345
+ Estimate the cost in USD for a given model and token counts.
346
+
347
+ Args:
348
+ model: Model name (e.g., "gpt-4o", "claude-3-5-sonnet-20241022")
349
+ prompt_tokens: Number of input/prompt tokens
350
+ completion_tokens: Number of output/completion tokens
351
+
352
+ Returns:
353
+ Estimated cost in USD
354
+ """
355
+ costs = MODEL_COSTS.get(model, MODEL_COSTS["default"])
356
+ input_cost = (prompt_tokens / 1000) * costs["input"]
357
+ output_cost = (completion_tokens / 1000) * costs["output"]
358
+ return input_cost + output_cost
@@ -0,0 +1,233 @@
1
+ """
2
+ Pytest fixtures for LLM mocking.
3
+
4
+ These fixtures are automatically available when pytest-mockllm is installed.
5
+ No configuration or imports needed - just use them in your tests!
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, Generator, Optional
13
+
14
+ import pytest
15
+
16
+ from pytest_mockllm.providers.openai import OpenAIMock
17
+ from pytest_mockllm.providers.anthropic import AnthropicMock
18
+ from pytest_mockllm.providers.gemini import GeminiMock
19
+ from pytest_mockllm.recording import LLMRecorder
20
+
21
+ if TYPE_CHECKING:
22
+ from _pytest.fixtures import FixtureRequest
23
+
24
+
25
+ @pytest.fixture
26
+ def mock_openai() -> Generator[OpenAIMock, None, None]:
27
+ """
28
+ Mock OpenAI API for testing.
29
+
30
+ Automatically patches the OpenAI client to return mock responses.
31
+ No API key or network access required.
32
+
33
+ Example:
34
+ >>> def test_my_chatbot(mock_openai):
35
+ ... mock_openai.add_response("Hello! How can I help?")
36
+ ...
37
+ ... from openai import OpenAI
38
+ ... client = OpenAI(api_key="fake")
39
+ ... response = client.chat.completions.create(
40
+ ... model="gpt-4o",
41
+ ... messages=[{"role": "user", "content": "Hi!"}]
42
+ ... )
43
+ ...
44
+ ... assert "help" in response.choices[0].message.content.lower()
45
+ ... assert mock_openai.call_count == 1
46
+
47
+ Advanced Example (streaming):
48
+ >>> def test_streaming(mock_openai):
49
+ ... mock_openai.add_response("This is a streamed response")
50
+ ...
51
+ ... client = OpenAI(api_key="fake")
52
+ ... stream = client.chat.completions.create(
53
+ ... model="gpt-4o",
54
+ ... messages=[{"role": "user", "content": "Hi!"}],
55
+ ... stream=True,
56
+ ... )
57
+ ...
58
+ ... full_response = ""
59
+ ... for chunk in stream:
60
+ ... if chunk.choices[0].delta.content:
61
+ ... full_response += chunk.choices[0].delta.content
62
+ ...
63
+ ... assert "streamed" in full_response
64
+ """
65
+ with OpenAIMock() as mock:
66
+ yield mock
67
+
68
+
69
+ @pytest.fixture
70
+ def mock_anthropic() -> Generator[AnthropicMock, None, None]:
71
+ """
72
+ Mock Anthropic Claude API for testing.
73
+
74
+ Automatically patches the Anthropic client to return mock responses.
75
+ No API key or network access required.
76
+
77
+ Example:
78
+ >>> def test_claude_bot(mock_anthropic):
79
+ ... mock_anthropic.add_response("I'd be happy to help!")
80
+ ...
81
+ ... from anthropic import Anthropic
82
+ ... client = Anthropic(api_key="fake")
83
+ ... response = client.messages.create(
84
+ ... model="claude-3-5-sonnet-20241022",
85
+ ... max_tokens=1024,
86
+ ... messages=[{"role": "user", "content": "Hello!"}]
87
+ ... )
88
+ ...
89
+ ... assert "happy" in response.content[0].text.lower()
90
+ """
91
+ with AnthropicMock() as mock:
92
+ yield mock
93
+
94
+
95
+ @pytest.fixture
96
+ def mock_gemini() -> Generator[GeminiMock, None, None]:
97
+ """
98
+ Mock Google Gemini API for testing.
99
+
100
+ Automatically patches the Google GenerativeAI client to return mock responses.
101
+ No API key or network access required.
102
+
103
+ Example:
104
+ >>> def test_gemini_bot(mock_gemini):
105
+ ... mock_gemini.add_response("Here's what I found...")
106
+ ...
107
+ ... import google.generativeai as genai
108
+ ... model = genai.GenerativeModel("gemini-1.5-pro")
109
+ ... response = model.generate_content("Tell me about AI")
110
+ ...
111
+ ... assert "found" in response.text.lower()
112
+ """
113
+ with GeminiMock() as mock:
114
+ yield mock
115
+
116
+
117
+ @pytest.fixture
118
+ def mock_llm(request: "FixtureRequest") -> Generator[OpenAIMock, None, None]:
119
+ """
120
+ Universal LLM mock - defaults to OpenAI.
121
+
122
+ Use the @pytest.mark.llm_mock(provider="anthropic") marker to switch providers.
123
+
124
+ Example:
125
+ >>> def test_with_default(mock_llm):
126
+ ... mock_llm.add_response("Works with OpenAI by default")
127
+ ... # ... your test code
128
+
129
+ >>> @pytest.mark.llm_mock(provider="anthropic")
130
+ ... def test_with_anthropic(mock_llm):
131
+ ... mock_llm.add_response("Now uses Anthropic!")
132
+ ... # ... your test code
133
+ """
134
+ # Check for marker to determine provider
135
+ marker = request.node.get_closest_marker("llm_mock")
136
+ provider = "openai"
137
+
138
+ if marker:
139
+ provider = marker.kwargs.get("provider", "openai")
140
+
141
+ mock_classes = {
142
+ "openai": OpenAIMock,
143
+ "anthropic": AnthropicMock,
144
+ "gemini": GeminiMock,
145
+ }
146
+
147
+ mock_class = mock_classes.get(provider, OpenAIMock)
148
+
149
+ with mock_class() as mock:
150
+ yield mock
151
+
152
+
153
+ @pytest.fixture
154
+ def mock_langchain() -> Generator["LangChainMock", None, None]:
155
+ """
156
+ Mock LangChain LLM and ChatModel for testing.
157
+
158
+ Provides a mock that works with LangChain's ChatModel interface,
159
+ including chains, agents, and other LangChain components.
160
+
161
+ Example:
162
+ >>> def test_langchain_chain(mock_langchain):
163
+ ... mock_langchain.add_response("The answer is 42.")
164
+ ...
165
+ ... from langchain_core.prompts import ChatPromptTemplate
166
+ ... from langchain_openai import ChatOpenAI
167
+ ...
168
+ ... prompt = ChatPromptTemplate.from_template("Question: {question}")
169
+ ... llm = ChatOpenAI(model="gpt-4o", api_key="fake")
170
+ ... chain = prompt | llm
171
+ ...
172
+ ... result = chain.invoke({"question": "What is the meaning of life?"})
173
+ ... assert "42" in result.content
174
+ """
175
+ from pytest_mockllm.integrations.langchain import LangChainMock
176
+
177
+ with LangChainMock() as mock:
178
+ yield mock
179
+
180
+
181
+ @pytest.fixture
182
+ def llm_recorder(request: "FixtureRequest") -> Generator[LLMRecorder, None, None]:
183
+ """
184
+ Record and replay LLM API responses (VCR-style).
185
+
186
+ On first run, makes real API calls and saves responses to cassettes.
187
+ On subsequent runs, replays saved responses without network access.
188
+
189
+ Requires actual API keys for recording mode.
190
+
191
+ Example:
192
+ >>> @pytest.mark.llm_record
193
+ ... def test_with_recording(llm_recorder):
194
+ ... # First run: hits real API, saves response
195
+ ... # Subsequent runs: uses saved response
196
+ ...
197
+ ... from openai import OpenAI
198
+ ... client = OpenAI() # Uses real API key
199
+ ... response = client.chat.completions.create(
200
+ ... model="gpt-4o-mini",
201
+ ... messages=[{"role": "user", "content": "Say hello!"}]
202
+ ... )
203
+ ...
204
+ ... assert response.choices[0].message.content
205
+ """
206
+ # Get cassette directory from config or use default
207
+ cassette_dir = request.config.getoption("--llm-cassette-dir", default="tests/llm_cassettes")
208
+ record_mode = request.config.getoption("--llm-record", default=False)
209
+
210
+ # Use test name as cassette name
211
+ test_name = request.node.name
212
+ cassette_path = Path(cassette_dir) / f"{test_name}.yaml"
213
+
214
+ # Check for llm_record or llm_replay markers
215
+ record_marker = request.node.get_closest_marker("llm_record")
216
+ replay_marker = request.node.get_closest_marker("llm_replay")
217
+
218
+ mode = "auto"
219
+ if record_marker or record_mode:
220
+ mode = "record"
221
+ elif replay_marker:
222
+ mode = "replay"
223
+
224
+ recorder = LLMRecorder(cassette_path=cassette_path, mode=mode)
225
+
226
+ with recorder:
227
+ yield recorder
228
+
229
+
230
+ # Type alias for the LangChain mock (defined here to avoid circular import)
231
+ class LangChainMock:
232
+ """Placeholder - real implementation in integrations/langchain.py"""
233
+ pass
@@ -0,0 +1,5 @@
1
+ """LangChain and LlamaIndex integration modules."""
2
+
3
+ from pytest_mockllm.integrations.langchain import LangChainMock
4
+
5
+ __all__ = ["LangChainMock"]