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.
- pytest_mockllm/__init__.py +39 -0
- pytest_mockllm/core.py +358 -0
- pytest_mockllm/fixtures.py +233 -0
- pytest_mockllm/integrations/__init__.py +5 -0
- pytest_mockllm/integrations/langchain.py +253 -0
- pytest_mockllm/plugin.py +81 -0
- pytest_mockllm/providers/__init__.py +11 -0
- pytest_mockllm/providers/anthropic.py +286 -0
- pytest_mockllm/providers/gemini.py +212 -0
- pytest_mockllm/providers/openai.py +346 -0
- pytest_mockllm/recording.py +231 -0
- pytest_mockllm-0.1.0.dist-info/METADATA +544 -0
- pytest_mockllm-0.1.0.dist-info/RECORD +16 -0
- pytest_mockllm-0.1.0.dist-info/WHEEL +4 -0
- pytest_mockllm-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_mockllm-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|