agentverify 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.
- agentverify/__init__.py +42 -0
- agentverify/assertions.py +324 -0
- agentverify/cassette/__init__.py +1 -0
- agentverify/cassette/adapters/__init__.py +1 -0
- agentverify/cassette/adapters/anthropic.py +274 -0
- agentverify/cassette/adapters/base.py +72 -0
- agentverify/cassette/adapters/bedrock.py +263 -0
- agentverify/cassette/adapters/gemini.py +363 -0
- agentverify/cassette/adapters/litellm.py +129 -0
- agentverify/cassette/adapters/openai.py +291 -0
- agentverify/cassette/io.py +207 -0
- agentverify/cassette/recorder.py +253 -0
- agentverify/errors.py +149 -0
- agentverify/fixtures.py +79 -0
- agentverify/markers.py +18 -0
- agentverify/matchers.py +48 -0
- agentverify/models.py +109 -0
- agentverify/plugin.py +26 -0
- agentverify/py.typed +1 -0
- agentverify-0.1.0.dist-info/METADATA +490 -0
- agentverify-0.1.0.dist-info/RECORD +25 -0
- agentverify-0.1.0.dist-info/WHEEL +5 -0
- agentverify-0.1.0.dist-info/entry_points.txt +2 -0
- agentverify-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentverify-0.1.0.dist-info/top_level.txt +1 -0
agentverify/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""agentverify — deterministic testing for AI agent tool call sequences.
|
|
2
|
+
|
|
3
|
+
Public API re-exports for convenient access::
|
|
4
|
+
|
|
5
|
+
from agentverify import (
|
|
6
|
+
ToolCall, ExecutionResult, TokenUsage,
|
|
7
|
+
ANY, OrderMode,
|
|
8
|
+
assert_tool_calls, assert_cost, assert_no_tool_call,
|
|
9
|
+
assert_final_output, assert_all,
|
|
10
|
+
LLMCassetteRecorder, CassetteMode,
|
|
11
|
+
)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from agentverify.assertions import (
|
|
15
|
+
assert_all,
|
|
16
|
+
assert_cost,
|
|
17
|
+
assert_final_output,
|
|
18
|
+
assert_no_tool_call,
|
|
19
|
+
assert_tool_calls,
|
|
20
|
+
)
|
|
21
|
+
from agentverify.cassette.recorder import CassetteMode, LLMCassetteRecorder
|
|
22
|
+
from agentverify.matchers import ANY, OrderMode
|
|
23
|
+
from agentverify.models import ExecutionResult, TokenUsage, ToolCall
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
# Data models
|
|
27
|
+
"ToolCall",
|
|
28
|
+
"ExecutionResult",
|
|
29
|
+
"TokenUsage",
|
|
30
|
+
# Matchers
|
|
31
|
+
"ANY",
|
|
32
|
+
"OrderMode",
|
|
33
|
+
# Assertions
|
|
34
|
+
"assert_tool_calls",
|
|
35
|
+
"assert_cost",
|
|
36
|
+
"assert_no_tool_call",
|
|
37
|
+
"assert_final_output",
|
|
38
|
+
"assert_all",
|
|
39
|
+
# Cassette
|
|
40
|
+
"LLMCassetteRecorder",
|
|
41
|
+
"CassetteMode",
|
|
42
|
+
]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Assertion engine for agentverify.
|
|
2
|
+
|
|
3
|
+
Provides assertion functions for verifying tool call sequences,
|
|
4
|
+
cost/token budgets, safety guardrails, and batch assertion execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Callable, Optional
|
|
10
|
+
|
|
11
|
+
from agentverify.errors import (
|
|
12
|
+
AgentVerifyError,
|
|
13
|
+
CostBudgetError,
|
|
14
|
+
FinalOutputError,
|
|
15
|
+
MultipleAssertionError,
|
|
16
|
+
SafetyRuleViolationError,
|
|
17
|
+
ToolCallSequenceError,
|
|
18
|
+
)
|
|
19
|
+
from agentverify.matchers import OrderMode
|
|
20
|
+
from agentverify.models import ExecutionResult, ToolCall
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _tool_call_matches(
|
|
24
|
+
expected: ToolCall,
|
|
25
|
+
actual: ToolCall,
|
|
26
|
+
partial_args: bool,
|
|
27
|
+
) -> bool:
|
|
28
|
+
"""Check whether two ToolCalls match.
|
|
29
|
+
|
|
30
|
+
Names must match exactly. Argument matching depends on *partial_args*:
|
|
31
|
+
- False: arguments must be equal (ANY matcher still works via __eq__)
|
|
32
|
+
- True: only keys present in expected.arguments are checked
|
|
33
|
+
"""
|
|
34
|
+
if expected.name != actual.name:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
if partial_args:
|
|
38
|
+
for key, value in expected.arguments.items():
|
|
39
|
+
if key not in actual.arguments:
|
|
40
|
+
return False
|
|
41
|
+
if value != actual.arguments[key]:
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
return expected.arguments == actual.arguments
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def assert_tool_calls(
|
|
49
|
+
result: ExecutionResult,
|
|
50
|
+
expected: list[ToolCall],
|
|
51
|
+
order: OrderMode = OrderMode.EXACT,
|
|
52
|
+
partial_args: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Verify the tool call sequence in an ExecutionResult.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
result: The ExecutionResult to verify.
|
|
58
|
+
expected: Expected ToolCall list.
|
|
59
|
+
order: Comparison mode (EXACT, IN_ORDER, ANY_ORDER).
|
|
60
|
+
partial_args: When True, only keys present in expected arguments
|
|
61
|
+
are checked against actual arguments.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ToolCallSequenceError: When a mismatch is detected.
|
|
65
|
+
"""
|
|
66
|
+
actual = result.tool_calls
|
|
67
|
+
|
|
68
|
+
if order == OrderMode.EXACT:
|
|
69
|
+
_assert_exact(expected, actual, partial_args)
|
|
70
|
+
elif order == OrderMode.IN_ORDER:
|
|
71
|
+
_assert_in_order(expected, actual, partial_args)
|
|
72
|
+
elif order == OrderMode.ANY_ORDER:
|
|
73
|
+
_assert_any_order(expected, actual, partial_args)
|
|
74
|
+
else: # pragma: no cover — OrderMode is a closed enum
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _assert_exact(
|
|
79
|
+
expected: list[ToolCall],
|
|
80
|
+
actual: list[ToolCall],
|
|
81
|
+
partial_args: bool,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""EXACT mode: same length and same tool calls at each position."""
|
|
84
|
+
if len(expected) != len(actual):
|
|
85
|
+
# first_mismatch_index is the first position that differs,
|
|
86
|
+
# or min(len(expected), len(actual)) if one list is shorter.
|
|
87
|
+
mismatch = min(len(expected), len(actual))
|
|
88
|
+
for i in range(mismatch):
|
|
89
|
+
if not _tool_call_matches(expected[i], actual[i], partial_args):
|
|
90
|
+
mismatch = i
|
|
91
|
+
break
|
|
92
|
+
raise ToolCallSequenceError(
|
|
93
|
+
expected=expected,
|
|
94
|
+
actual=actual,
|
|
95
|
+
first_mismatch_index=mismatch,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for i in range(len(expected)):
|
|
99
|
+
if not _tool_call_matches(expected[i], actual[i], partial_args):
|
|
100
|
+
raise ToolCallSequenceError(
|
|
101
|
+
expected=expected,
|
|
102
|
+
actual=actual,
|
|
103
|
+
first_mismatch_index=i,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _assert_in_order(
|
|
108
|
+
expected: list[ToolCall],
|
|
109
|
+
actual: list[ToolCall],
|
|
110
|
+
partial_args: bool,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""IN_ORDER mode: expected must be a subsequence of actual.
|
|
113
|
+
|
|
114
|
+
Uses a two-pointer approach: walk through actual looking for each
|
|
115
|
+
expected element in order.
|
|
116
|
+
"""
|
|
117
|
+
if not expected:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
exp_idx = 0
|
|
121
|
+
for act_idx in range(len(actual)):
|
|
122
|
+
if exp_idx < len(expected) and _tool_call_matches(
|
|
123
|
+
expected[exp_idx], actual[act_idx], partial_args
|
|
124
|
+
):
|
|
125
|
+
exp_idx += 1
|
|
126
|
+
|
|
127
|
+
if exp_idx < len(expected):
|
|
128
|
+
# The first unmatched expected element is the mismatch point
|
|
129
|
+
raise ToolCallSequenceError(
|
|
130
|
+
expected=expected,
|
|
131
|
+
actual=actual,
|
|
132
|
+
first_mismatch_index=exp_idx,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _assert_any_order(
|
|
137
|
+
expected: list[ToolCall],
|
|
138
|
+
actual: list[ToolCall],
|
|
139
|
+
partial_args: bool,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""ANY_ORDER mode: all expected tool calls must exist in actual."""
|
|
142
|
+
for i, exp_tc in enumerate(expected):
|
|
143
|
+
found = any(
|
|
144
|
+
_tool_call_matches(exp_tc, act_tc, partial_args)
|
|
145
|
+
for act_tc in actual
|
|
146
|
+
)
|
|
147
|
+
if not found:
|
|
148
|
+
raise ToolCallSequenceError(
|
|
149
|
+
expected=expected,
|
|
150
|
+
actual=actual,
|
|
151
|
+
first_mismatch_index=i,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def assert_cost(
|
|
156
|
+
result: ExecutionResult,
|
|
157
|
+
max_tokens: Optional[int] = None,
|
|
158
|
+
max_cost_usd: Optional[float] = None,
|
|
159
|
+
strict: bool = False,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Verify that cost/token consumption is within budget.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
result: The ExecutionResult to verify.
|
|
165
|
+
max_tokens: Maximum allowed total tokens.
|
|
166
|
+
max_cost_usd: Maximum allowed cost in USD.
|
|
167
|
+
strict: When True, raise CostBudgetError if token_usage or
|
|
168
|
+
total_cost_usd is None when the corresponding limit is set.
|
|
169
|
+
Default False silently passes when data is unavailable.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
CostBudgetError: When budget is exceeded or when strict=True
|
|
173
|
+
and the required data is missing.
|
|
174
|
+
"""
|
|
175
|
+
if max_tokens is not None:
|
|
176
|
+
if result.token_usage is not None:
|
|
177
|
+
actual_tokens = result.token_usage.total_tokens
|
|
178
|
+
if actual_tokens > max_tokens:
|
|
179
|
+
raise CostBudgetError(
|
|
180
|
+
actual=actual_tokens,
|
|
181
|
+
limit=max_tokens,
|
|
182
|
+
exceeded_by=actual_tokens - max_tokens,
|
|
183
|
+
)
|
|
184
|
+
elif strict:
|
|
185
|
+
raise CostBudgetError(
|
|
186
|
+
actual=0,
|
|
187
|
+
limit=max_tokens,
|
|
188
|
+
exceeded_by=0,
|
|
189
|
+
message="token_usage is None but strict mode requires it",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if max_cost_usd is not None:
|
|
193
|
+
if result.total_cost_usd is not None:
|
|
194
|
+
if result.total_cost_usd > max_cost_usd:
|
|
195
|
+
raise CostBudgetError(
|
|
196
|
+
actual=result.total_cost_usd,
|
|
197
|
+
limit=max_cost_usd,
|
|
198
|
+
exceeded_by=result.total_cost_usd - max_cost_usd,
|
|
199
|
+
)
|
|
200
|
+
elif strict:
|
|
201
|
+
raise CostBudgetError(
|
|
202
|
+
actual=0.0,
|
|
203
|
+
limit=max_cost_usd,
|
|
204
|
+
exceeded_by=0.0,
|
|
205
|
+
message="total_cost_usd is None but strict mode requires it",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def assert_no_tool_call(
|
|
210
|
+
result: ExecutionResult,
|
|
211
|
+
forbidden_tools: list[str],
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Verify that no forbidden tools were called.
|
|
214
|
+
|
|
215
|
+
All violations are collected and reported at once.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
result: The ExecutionResult to verify.
|
|
219
|
+
forbidden_tools: List of forbidden tool names.
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
SafetyRuleViolationError: When forbidden tool calls are detected.
|
|
223
|
+
"""
|
|
224
|
+
forbidden_set = set(forbidden_tools)
|
|
225
|
+
violations: list[dict] = []
|
|
226
|
+
|
|
227
|
+
for position, tc in enumerate(result.tool_calls):
|
|
228
|
+
if tc.name in forbidden_set:
|
|
229
|
+
violations.append(
|
|
230
|
+
{
|
|
231
|
+
"tool_name": tc.name,
|
|
232
|
+
"arguments": tc.arguments,
|
|
233
|
+
"position": position,
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if violations:
|
|
238
|
+
raise SafetyRuleViolationError(violations=violations)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def assert_final_output(
|
|
242
|
+
result: ExecutionResult,
|
|
243
|
+
contains: Optional[str] = None,
|
|
244
|
+
equals: Optional[str] = None,
|
|
245
|
+
matches: Optional[str] = None,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Verify the agent's final text output.
|
|
248
|
+
|
|
249
|
+
At least one of *contains*, *equals*, or *matches* must be provided.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
result: The ExecutionResult to verify.
|
|
253
|
+
contains: Substring that must appear in final_output.
|
|
254
|
+
equals: Exact string that final_output must equal.
|
|
255
|
+
matches: Regex pattern that final_output must match.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
FinalOutputError: When the final output does not meet expectations.
|
|
259
|
+
"""
|
|
260
|
+
if contains is None and equals is None and matches is None:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
"assert_final_output requires at least one of: contains, equals, matches"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if result.final_output is None:
|
|
266
|
+
raise FinalOutputError("final_output is None")
|
|
267
|
+
|
|
268
|
+
if equals is not None:
|
|
269
|
+
if result.final_output != equals:
|
|
270
|
+
raise FinalOutputError(
|
|
271
|
+
f"final_output does not equal expected\n"
|
|
272
|
+
f"\n"
|
|
273
|
+
f" Expected: {equals!r}\n"
|
|
274
|
+
f" Actual: {result.final_output!r}"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if contains is not None:
|
|
278
|
+
if contains not in result.final_output:
|
|
279
|
+
raise FinalOutputError(
|
|
280
|
+
f"final_output does not contain expected substring\n"
|
|
281
|
+
f"\n"
|
|
282
|
+
f" Substring: {contains!r}\n"
|
|
283
|
+
f" Actual: {result.final_output!r}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if matches is not None:
|
|
287
|
+
import re
|
|
288
|
+
|
|
289
|
+
if not re.search(matches, result.final_output):
|
|
290
|
+
raise FinalOutputError(
|
|
291
|
+
f"final_output does not match pattern\n"
|
|
292
|
+
f"\n"
|
|
293
|
+
f" Pattern: {matches!r}\n"
|
|
294
|
+
f" Actual: {result.final_output!r}"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def assert_all(
|
|
299
|
+
result: ExecutionResult,
|
|
300
|
+
*assertions: Callable[[ExecutionResult], None],
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Execute multiple assertions, collecting all failures.
|
|
303
|
+
|
|
304
|
+
Each assertion is a callable that takes an ExecutionResult and
|
|
305
|
+
returns None or raises an AgentVerifyError. All assertions are
|
|
306
|
+
executed even if some fail.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
result: The ExecutionResult to verify.
|
|
310
|
+
*assertions: Callable assertions to execute.
|
|
311
|
+
|
|
312
|
+
Raises:
|
|
313
|
+
MultipleAssertionError: When one or more assertions fail.
|
|
314
|
+
"""
|
|
315
|
+
errors: list[AgentVerifyError] = []
|
|
316
|
+
|
|
317
|
+
for assertion in assertions:
|
|
318
|
+
try:
|
|
319
|
+
assertion(result)
|
|
320
|
+
except AgentVerifyError as exc:
|
|
321
|
+
errors.append(exc)
|
|
322
|
+
|
|
323
|
+
if errors:
|
|
324
|
+
raise MultipleAssertionError(errors=errors)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Anthropic SDK adapter for LLM cassette recording and replay.
|
|
2
|
+
|
|
3
|
+
Monkey-patches ``anthropic.resources.messages.Messages.create``
|
|
4
|
+
to intercept message creation calls for recording and replaying via the
|
|
5
|
+
cassette recorder.
|
|
6
|
+
|
|
7
|
+
anthropic is an **optional** dependency. Import this module only when the
|
|
8
|
+
anthropic extra is installed (``pip install agentverify[anthropic]``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Generator
|
|
16
|
+
from unittest.mock import patch
|
|
17
|
+
|
|
18
|
+
from agentverify.cassette.adapters.base import (
|
|
19
|
+
LLMProviderAdapter,
|
|
20
|
+
NormalizedRequest,
|
|
21
|
+
NormalizedResponse,
|
|
22
|
+
)
|
|
23
|
+
from agentverify.models import TokenUsage
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from agentverify.cassette.recorder import LLMCassetteRecorder
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import anthropic as _anthropic_module # noqa: F401 – presence check
|
|
30
|
+
except ImportError: # pragma: no cover
|
|
31
|
+
_anthropic_module = None # type: ignore[assignment]
|
|
32
|
+
|
|
33
|
+
_PATCH_TARGET = "anthropic.resources.messages.Messages.create"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ensure_anthropic_installed() -> None:
|
|
37
|
+
"""Raise a clear error when the anthropic package is missing."""
|
|
38
|
+
if _anthropic_module is None: # pragma: no cover
|
|
39
|
+
raise ImportError(
|
|
40
|
+
"The anthropic package is required to use AnthropicAdapter. "
|
|
41
|
+
"Install it with: pip install agentverify[anthropic]"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Lightweight response objects used by denormalize_response so that callers
|
|
47
|
+
# can access ``response.content``, ``response.usage``, etc. without needing a
|
|
48
|
+
# real ``anthropic.types.Message`` instance.
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Usage:
|
|
53
|
+
"""Minimal stand-in for ``anthropic.types.Usage``."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, input_tokens: int, output_tokens: int) -> None:
|
|
56
|
+
self.input_tokens = input_tokens
|
|
57
|
+
self.output_tokens = output_tokens
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _TextBlock:
|
|
61
|
+
"""Minimal stand-in for ``anthropic.types.TextBlock``."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, text: str) -> None:
|
|
64
|
+
self.type = "text"
|
|
65
|
+
self.text = text
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _ToolUseBlock:
|
|
69
|
+
"""Minimal stand-in for ``anthropic.types.ToolUseBlock``."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, id: str, name: str, input: dict[str, Any]) -> None:
|
|
72
|
+
self.type = "tool_use"
|
|
73
|
+
self.id = id
|
|
74
|
+
self.name = name
|
|
75
|
+
self.input = input
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Message:
|
|
79
|
+
"""Minimal stand-in for ``anthropic.types.Message``."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
content: list[_TextBlock | _ToolUseBlock],
|
|
84
|
+
usage: _Usage | None,
|
|
85
|
+
stop_reason: str = "end_turn",
|
|
86
|
+
model: str = "",
|
|
87
|
+
id: str = "",
|
|
88
|
+
) -> None:
|
|
89
|
+
self.content = content
|
|
90
|
+
self.usage = usage
|
|
91
|
+
self.stop_reason = stop_reason
|
|
92
|
+
self.model = model
|
|
93
|
+
self.id = id
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Adapter implementation
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AnthropicAdapter(LLMProviderAdapter):
|
|
102
|
+
"""Adapter for the Anthropic Python SDK (``anthropic>=0.20``)."""
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def name(self) -> str:
|
|
106
|
+
return "anthropic"
|
|
107
|
+
|
|
108
|
+
# -- normalisation -------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def normalize_request(self, raw_request: dict[str, Any]) -> NormalizedRequest:
|
|
111
|
+
"""Convert Anthropic ``messages.create()`` kwargs to *NormalizedRequest*."""
|
|
112
|
+
messages = raw_request.get("messages", [])
|
|
113
|
+
model = raw_request.get("model", "")
|
|
114
|
+
|
|
115
|
+
# Normalise tools: Anthropic format uses
|
|
116
|
+
# {"name": ..., "input_schema": {...}}
|
|
117
|
+
raw_tools = raw_request.get("tools")
|
|
118
|
+
tools: list[dict[str, Any]] | None = None
|
|
119
|
+
if raw_tools is not None:
|
|
120
|
+
tools = []
|
|
121
|
+
for t in raw_tools:
|
|
122
|
+
tools.append(
|
|
123
|
+
{
|
|
124
|
+
"name": t.get("name", ""),
|
|
125
|
+
"parameters": t.get("input_schema", {}),
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Everything else goes into parameters
|
|
130
|
+
_reserved = {"messages", "model", "tools"}
|
|
131
|
+
parameters = {k: v for k, v in raw_request.items() if k not in _reserved}
|
|
132
|
+
|
|
133
|
+
return NormalizedRequest(
|
|
134
|
+
messages=messages,
|
|
135
|
+
model=model,
|
|
136
|
+
tools=tools,
|
|
137
|
+
parameters=parameters,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def normalize_response(self, raw_response: Any) -> NormalizedResponse:
|
|
141
|
+
"""Convert an Anthropic ``Message`` response to *NormalizedResponse*."""
|
|
142
|
+
content: str | None = None
|
|
143
|
+
tool_calls: list[dict[str, Any]] | None = None
|
|
144
|
+
|
|
145
|
+
for block in raw_response.content:
|
|
146
|
+
block_type = getattr(block, "type", None)
|
|
147
|
+
if block_type == "text":
|
|
148
|
+
if content is None:
|
|
149
|
+
content = block.text
|
|
150
|
+
else:
|
|
151
|
+
content += block.text
|
|
152
|
+
elif block_type == "tool_use":
|
|
153
|
+
if tool_calls is None:
|
|
154
|
+
tool_calls = []
|
|
155
|
+
tool_calls.append(
|
|
156
|
+
{
|
|
157
|
+
"name": block.name,
|
|
158
|
+
"arguments": json.dumps(block.input),
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
token_usage: TokenUsage | None = None
|
|
163
|
+
if raw_response.usage is not None:
|
|
164
|
+
token_usage = TokenUsage(
|
|
165
|
+
input_tokens=raw_response.usage.input_tokens,
|
|
166
|
+
output_tokens=raw_response.usage.output_tokens,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return NormalizedResponse(
|
|
170
|
+
content=content,
|
|
171
|
+
tool_calls=tool_calls,
|
|
172
|
+
token_usage=token_usage,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def denormalize_response(self, normalized: NormalizedResponse) -> Any:
|
|
176
|
+
"""Build a lightweight object that mimics an Anthropic ``Message``."""
|
|
177
|
+
content_blocks: list[_TextBlock | _ToolUseBlock] = []
|
|
178
|
+
|
|
179
|
+
if normalized.content is not None:
|
|
180
|
+
content_blocks.append(_TextBlock(text=normalized.content))
|
|
181
|
+
|
|
182
|
+
if normalized.tool_calls:
|
|
183
|
+
for i, tc in enumerate(normalized.tool_calls):
|
|
184
|
+
arguments = tc["arguments"]
|
|
185
|
+
if isinstance(arguments, str):
|
|
186
|
+
try:
|
|
187
|
+
arguments = json.loads(arguments)
|
|
188
|
+
except (json.JSONDecodeError, ValueError):
|
|
189
|
+
arguments = {}
|
|
190
|
+
content_blocks.append(
|
|
191
|
+
_ToolUseBlock(
|
|
192
|
+
id=f"toolu_{i}",
|
|
193
|
+
name=tc["name"],
|
|
194
|
+
input=arguments,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
stop_reason = "tool_use" if normalized.tool_calls else "end_turn"
|
|
199
|
+
|
|
200
|
+
usage: _Usage | None = None
|
|
201
|
+
if normalized.token_usage is not None:
|
|
202
|
+
usage = _Usage(
|
|
203
|
+
input_tokens=normalized.token_usage.input_tokens,
|
|
204
|
+
output_tokens=normalized.token_usage.output_tokens,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
model = normalized.raw_metadata.get("model", "")
|
|
208
|
+
message_id = normalized.raw_metadata.get("id", "")
|
|
209
|
+
|
|
210
|
+
return _Message(
|
|
211
|
+
content=content_blocks,
|
|
212
|
+
usage=usage,
|
|
213
|
+
stop_reason=stop_reason,
|
|
214
|
+
model=model,
|
|
215
|
+
id=message_id,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# -- monkey-patching -----------------------------------------------------
|
|
219
|
+
|
|
220
|
+
@contextmanager
|
|
221
|
+
def patch(self, recorder: LLMCassetteRecorder) -> Generator[None, None, None]:
|
|
222
|
+
"""Monkey-patch ``anthropic.resources.messages.Messages.create``.
|
|
223
|
+
|
|
224
|
+
In **record** mode the real API is called, the response is normalised,
|
|
225
|
+
recorded into the cassette, and the original response is returned.
|
|
226
|
+
|
|
227
|
+
In **replay** mode the request is normalised, looked up in the
|
|
228
|
+
cassette, denormalised, and returned without calling the real API.
|
|
229
|
+
"""
|
|
230
|
+
_ensure_anthropic_installed()
|
|
231
|
+
|
|
232
|
+
adapter = self
|
|
233
|
+
|
|
234
|
+
def _patched_create(original_fn): # type: ignore[no-untyped-def]
|
|
235
|
+
"""Return a wrapper that intercepts ``create()`` calls."""
|
|
236
|
+
|
|
237
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
238
|
+
from agentverify.cassette.recorder import CassetteMode, OnMissingRequest
|
|
239
|
+
|
|
240
|
+
norm_req = adapter.normalize_request(kwargs)
|
|
241
|
+
|
|
242
|
+
if recorder.mode in (CassetteMode.RECORD, CassetteMode.AUTO):
|
|
243
|
+
# Call the real API and record the interaction
|
|
244
|
+
response = original_fn(*args, **kwargs)
|
|
245
|
+
norm_resp = adapter.normalize_response(response)
|
|
246
|
+
recorder.record(norm_req, norm_resp)
|
|
247
|
+
return response
|
|
248
|
+
|
|
249
|
+
# REPLAY mode
|
|
250
|
+
norm_resp = recorder.lookup(norm_req)
|
|
251
|
+
if norm_resp is None:
|
|
252
|
+
if recorder.on_missing == OnMissingRequest.FALLBACK:
|
|
253
|
+
response = original_fn(*args, **kwargs)
|
|
254
|
+
norm_resp = adapter.normalize_response(response)
|
|
255
|
+
recorder.record(norm_req, norm_resp)
|
|
256
|
+
return response
|
|
257
|
+
from agentverify.errors import CassetteMissingRequestError
|
|
258
|
+
|
|
259
|
+
raise CassetteMissingRequestError(
|
|
260
|
+
f"No matching cassette entry for request: model={norm_req.model}"
|
|
261
|
+
)
|
|
262
|
+
return adapter.denormalize_response(norm_resp)
|
|
263
|
+
|
|
264
|
+
return wrapper
|
|
265
|
+
|
|
266
|
+
# Use unittest.mock.patch to ensure clean teardown
|
|
267
|
+
with patch(
|
|
268
|
+
_PATCH_TARGET,
|
|
269
|
+
new=_patched_create(
|
|
270
|
+
# Grab the real function *before* patching
|
|
271
|
+
__import__("anthropic").resources.messages.Messages.create
|
|
272
|
+
),
|
|
273
|
+
):
|
|
274
|
+
yield
|