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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Base classes for LLM provider adapters.
|
|
2
|
+
|
|
3
|
+
Defines the provider-agnostic normalized request/response formats
|
|
4
|
+
and the abstract base class that all provider adapters must implement.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Generator
|
|
13
|
+
|
|
14
|
+
from agentverify.models import TokenUsage
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from agentverify.cassette.recorder import LLMCassetteRecorder
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class NormalizedRequest:
|
|
22
|
+
"""Provider-agnostic request format."""
|
|
23
|
+
|
|
24
|
+
messages: list[dict[str, Any]]
|
|
25
|
+
model: str
|
|
26
|
+
tools: list[dict[str, Any]] | None = None
|
|
27
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class NormalizedResponse:
|
|
32
|
+
"""Provider-agnostic response format."""
|
|
33
|
+
|
|
34
|
+
content: str | None = None
|
|
35
|
+
tool_calls: list[dict[str, Any]] | None = None
|
|
36
|
+
token_usage: TokenUsage | None = None
|
|
37
|
+
raw_metadata: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LLMProviderAdapter(ABC):
|
|
41
|
+
"""Abstract base class for LLM SDK chat completion adapters."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def name(self) -> str: # pragma: no cover — abstract method
|
|
46
|
+
"""Provider name (e.g. "openai", "anthropic")."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
@contextmanager
|
|
51
|
+
def patch(self, recorder: LLMCassetteRecorder) -> Generator[None, None, None]: # pragma: no cover — abstract method
|
|
52
|
+
"""Monkey-patch the LLM SDK's chat completion method.
|
|
53
|
+
|
|
54
|
+
Records or replays requests/responses through the recorder.
|
|
55
|
+
The patch is automatically removed when the context manager exits.
|
|
56
|
+
"""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def normalize_request(self, raw_request: dict[str, Any]) -> NormalizedRequest: # pragma: no cover — abstract method
|
|
61
|
+
"""Normalize an SDK-specific request into the common format."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def normalize_response(self, raw_response: Any) -> NormalizedResponse: # pragma: no cover — abstract method
|
|
66
|
+
"""Normalize an SDK-specific response into the common format."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def denormalize_response(self, normalized: NormalizedResponse) -> Any: # pragma: no cover — abstract method
|
|
71
|
+
"""Restore a normalized response back to the SDK-specific format."""
|
|
72
|
+
...
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Amazon Bedrock Converse API adapter for LLM cassette recording and replay.
|
|
2
|
+
|
|
3
|
+
Monkey-patches ``botocore.client.BaseClient._make_api_call`` to intercept
|
|
4
|
+
``Converse`` operations for recording and replaying via the cassette recorder.
|
|
5
|
+
|
|
6
|
+
boto3 is an **optional** dependency. Import this module only when the
|
|
7
|
+
boto3 extra is installed (``pip install agentverify[bedrock]``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Generator
|
|
15
|
+
from unittest.mock import patch
|
|
16
|
+
|
|
17
|
+
from agentverify.cassette.adapters.base import (
|
|
18
|
+
LLMProviderAdapter,
|
|
19
|
+
NormalizedRequest,
|
|
20
|
+
NormalizedResponse,
|
|
21
|
+
)
|
|
22
|
+
from agentverify.models import TokenUsage
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from agentverify.cassette.recorder import LLMCassetteRecorder
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import botocore.client # noqa: F401 – presence check
|
|
29
|
+
|
|
30
|
+
_botocore_available = True
|
|
31
|
+
except ImportError: # pragma: no cover
|
|
32
|
+
_botocore_available = False
|
|
33
|
+
|
|
34
|
+
_PATCH_TARGET = "botocore.client.BaseClient._make_api_call"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ensure_boto3_installed() -> None:
|
|
38
|
+
"""Raise a clear error when the boto3/botocore package is missing."""
|
|
39
|
+
if not _botocore_available: # pragma: no cover
|
|
40
|
+
raise ImportError(
|
|
41
|
+
"The boto3 package is required to use BedrockAdapter. "
|
|
42
|
+
"Install it with: pip install agentverify[bedrock]"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Adapter implementation
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BedrockAdapter(LLMProviderAdapter):
|
|
52
|
+
"""Adapter for the Amazon Bedrock Converse API (via boto3)."""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def name(self) -> str:
|
|
56
|
+
return "bedrock"
|
|
57
|
+
|
|
58
|
+
# -- normalisation -------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def normalize_request(self, raw_request: dict[str, Any]) -> NormalizedRequest:
|
|
61
|
+
"""Convert Bedrock ``converse()`` kwargs to *NormalizedRequest*.
|
|
62
|
+
|
|
63
|
+
Bedrock messages use ``[{"role": "user", "content": [{"text": "..."}]}]``.
|
|
64
|
+
We flatten single-text content blocks to plain strings for the
|
|
65
|
+
normalised format.
|
|
66
|
+
"""
|
|
67
|
+
raw_messages = raw_request.get("messages", [])
|
|
68
|
+
messages: list[dict[str, Any]] = []
|
|
69
|
+
for msg in raw_messages:
|
|
70
|
+
role = msg.get("role", "")
|
|
71
|
+
content_blocks = msg.get("content", [])
|
|
72
|
+
# Flatten: if all blocks are text, join them into a single string
|
|
73
|
+
if isinstance(content_blocks, list) and all(
|
|
74
|
+
isinstance(b, dict) and "text" in b for b in content_blocks
|
|
75
|
+
):
|
|
76
|
+
text_parts = [b["text"] for b in content_blocks]
|
|
77
|
+
content = text_parts[0] if len(text_parts) == 1 else "\n".join(text_parts)
|
|
78
|
+
messages.append({"role": role, "content": content})
|
|
79
|
+
else:
|
|
80
|
+
# Keep as-is for complex content (images, tool results, etc.)
|
|
81
|
+
messages.append({"role": role, "content": content_blocks})
|
|
82
|
+
|
|
83
|
+
model = raw_request.get("modelId", "")
|
|
84
|
+
|
|
85
|
+
# Normalise tools from toolConfig
|
|
86
|
+
tool_config = raw_request.get("toolConfig")
|
|
87
|
+
tools: list[dict[str, Any]] | None = None
|
|
88
|
+
if tool_config is not None:
|
|
89
|
+
raw_tools = tool_config.get("tools", [])
|
|
90
|
+
tools = []
|
|
91
|
+
for t in raw_tools:
|
|
92
|
+
spec = t.get("toolSpec", {})
|
|
93
|
+
tools.append(
|
|
94
|
+
{
|
|
95
|
+
"name": spec.get("name", ""),
|
|
96
|
+
"parameters": spec.get("inputSchema", {}).get("json", {}),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Everything else goes into parameters
|
|
101
|
+
_reserved = {"messages", "modelId", "toolConfig"}
|
|
102
|
+
parameters: dict[str, Any] = {}
|
|
103
|
+
# inferenceConfig and other top-level keys
|
|
104
|
+
for k, v in raw_request.items():
|
|
105
|
+
if k not in _reserved:
|
|
106
|
+
parameters[k] = v
|
|
107
|
+
|
|
108
|
+
return NormalizedRequest(
|
|
109
|
+
messages=messages,
|
|
110
|
+
model=model,
|
|
111
|
+
tools=tools,
|
|
112
|
+
parameters=parameters,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def normalize_response(self, raw_response: Any) -> NormalizedResponse:
|
|
116
|
+
"""Convert a Bedrock Converse response dict to *NormalizedResponse*.
|
|
117
|
+
|
|
118
|
+
Bedrock responses are plain dicts with structure:
|
|
119
|
+
``{"output": {"message": {"role": "assistant", "content": [...]}}, "usage": {...}}``
|
|
120
|
+
"""
|
|
121
|
+
output = raw_response.get("output", {})
|
|
122
|
+
message = output.get("message", {})
|
|
123
|
+
content_blocks = message.get("content", [])
|
|
124
|
+
|
|
125
|
+
content: str | None = None
|
|
126
|
+
tool_calls: list[dict[str, Any]] | None = None
|
|
127
|
+
|
|
128
|
+
for block in content_blocks:
|
|
129
|
+
if "text" in block:
|
|
130
|
+
# Concatenate text blocks (usually just one)
|
|
131
|
+
if content is None:
|
|
132
|
+
content = block["text"]
|
|
133
|
+
else:
|
|
134
|
+
content += "\n" + block["text"]
|
|
135
|
+
elif "toolUse" in block:
|
|
136
|
+
if tool_calls is None:
|
|
137
|
+
tool_calls = []
|
|
138
|
+
tu = block["toolUse"]
|
|
139
|
+
tool_calls.append(
|
|
140
|
+
{
|
|
141
|
+
"name": tu.get("name", ""),
|
|
142
|
+
"arguments": json.dumps(tu.get("input", {})),
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Token usage
|
|
147
|
+
token_usage: TokenUsage | None = None
|
|
148
|
+
usage = raw_response.get("usage")
|
|
149
|
+
if usage is not None:
|
|
150
|
+
token_usage = TokenUsage(
|
|
151
|
+
input_tokens=usage.get("inputTokens", 0),
|
|
152
|
+
output_tokens=usage.get("outputTokens", 0),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return NormalizedResponse(
|
|
156
|
+
content=content,
|
|
157
|
+
tool_calls=tool_calls,
|
|
158
|
+
token_usage=token_usage,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def denormalize_response(self, normalized: NormalizedResponse) -> dict[str, Any]:
|
|
162
|
+
"""Build a dict that mimics a Bedrock Converse response structure."""
|
|
163
|
+
content_blocks: list[dict[str, Any]] = []
|
|
164
|
+
|
|
165
|
+
if normalized.content is not None:
|
|
166
|
+
content_blocks.append({"text": normalized.content})
|
|
167
|
+
|
|
168
|
+
if normalized.tool_calls:
|
|
169
|
+
for tc in normalized.tool_calls:
|
|
170
|
+
arguments = tc["arguments"]
|
|
171
|
+
if isinstance(arguments, str):
|
|
172
|
+
try:
|
|
173
|
+
arguments = json.loads(arguments)
|
|
174
|
+
except (json.JSONDecodeError, ValueError):
|
|
175
|
+
arguments = {}
|
|
176
|
+
content_blocks.append(
|
|
177
|
+
{
|
|
178
|
+
"toolUse": {
|
|
179
|
+
"toolUseId": f"tooluse_{id(tc)}",
|
|
180
|
+
"name": tc["name"],
|
|
181
|
+
"input": arguments,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
stop_reason = "tool_use" if normalized.tool_calls else "end_turn"
|
|
187
|
+
|
|
188
|
+
result: dict[str, Any] = {
|
|
189
|
+
"output": {
|
|
190
|
+
"message": {
|
|
191
|
+
"role": "assistant",
|
|
192
|
+
"content": content_blocks,
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
"stopReason": stop_reason,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if normalized.token_usage is not None:
|
|
199
|
+
result["usage"] = {
|
|
200
|
+
"inputTokens": normalized.token_usage.input_tokens,
|
|
201
|
+
"outputTokens": normalized.token_usage.output_tokens,
|
|
202
|
+
"totalTokens": normalized.token_usage.total_tokens,
|
|
203
|
+
}
|
|
204
|
+
else:
|
|
205
|
+
result["usage"] = None
|
|
206
|
+
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
# -- monkey-patching -----------------------------------------------------
|
|
210
|
+
|
|
211
|
+
@contextmanager
|
|
212
|
+
def patch(self, recorder: LLMCassetteRecorder) -> Generator[None, None, None]:
|
|
213
|
+
"""Monkey-patch ``botocore.client.BaseClient._make_api_call``.
|
|
214
|
+
|
|
215
|
+
Only intercepts the ``Converse`` operation; all other API calls
|
|
216
|
+
pass through to the original implementation.
|
|
217
|
+
|
|
218
|
+
In **record** mode the real API is called, the response is normalised,
|
|
219
|
+
recorded into the cassette, and the original response is returned.
|
|
220
|
+
|
|
221
|
+
In **replay** mode the request is normalised, looked up in the
|
|
222
|
+
cassette, denormalised, and returned without calling the real API.
|
|
223
|
+
"""
|
|
224
|
+
_ensure_boto3_installed()
|
|
225
|
+
|
|
226
|
+
adapter = self
|
|
227
|
+
|
|
228
|
+
import botocore.client
|
|
229
|
+
|
|
230
|
+
original_fn = botocore.client.BaseClient._make_api_call
|
|
231
|
+
|
|
232
|
+
def _patched_make_api_call(self_client: Any, operation_name: str, api_params: dict[str, Any]) -> Any:
|
|
233
|
+
"""Intercept Converse calls; pass through everything else."""
|
|
234
|
+
if operation_name != "Converse":
|
|
235
|
+
return original_fn(self_client, operation_name, api_params)
|
|
236
|
+
|
|
237
|
+
from agentverify.cassette.recorder import CassetteMode, OnMissingRequest
|
|
238
|
+
|
|
239
|
+
norm_req = adapter.normalize_request(api_params)
|
|
240
|
+
|
|
241
|
+
if recorder.mode in (CassetteMode.RECORD, CassetteMode.AUTO):
|
|
242
|
+
response = original_fn(self_client, operation_name, api_params)
|
|
243
|
+
norm_resp = adapter.normalize_response(response)
|
|
244
|
+
recorder.record(norm_req, norm_resp)
|
|
245
|
+
return response
|
|
246
|
+
|
|
247
|
+
# REPLAY mode
|
|
248
|
+
norm_resp = recorder.lookup(norm_req)
|
|
249
|
+
if norm_resp is None:
|
|
250
|
+
if recorder.on_missing == OnMissingRequest.FALLBACK:
|
|
251
|
+
response = original_fn(self_client, operation_name, api_params)
|
|
252
|
+
norm_resp = adapter.normalize_response(response)
|
|
253
|
+
recorder.record(norm_req, norm_resp)
|
|
254
|
+
return response
|
|
255
|
+
from agentverify.errors import CassetteMissingRequestError
|
|
256
|
+
|
|
257
|
+
raise CassetteMissingRequestError(
|
|
258
|
+
f"No matching cassette entry for request: model={norm_req.model}"
|
|
259
|
+
)
|
|
260
|
+
return adapter.denormalize_response(norm_resp)
|
|
261
|
+
|
|
262
|
+
with patch(_PATCH_TARGET, new=_patched_make_api_call):
|
|
263
|
+
yield
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Google Gemini SDK adapter for LLM cassette recording and replay.
|
|
2
|
+
|
|
3
|
+
Monkey-patches ``google.genai.models.Models.generate_content`` to intercept
|
|
4
|
+
content generation calls for recording and replaying via the cassette recorder.
|
|
5
|
+
|
|
6
|
+
google-genai is an **optional** dependency. Import this module only when the
|
|
7
|
+
google-genai extra is installed (``pip install agentverify[gemini]``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Generator
|
|
15
|
+
from unittest.mock import patch
|
|
16
|
+
|
|
17
|
+
from agentverify.cassette.adapters.base import (
|
|
18
|
+
LLMProviderAdapter,
|
|
19
|
+
NormalizedRequest,
|
|
20
|
+
NormalizedResponse,
|
|
21
|
+
)
|
|
22
|
+
from agentverify.models import TokenUsage
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from agentverify.cassette.recorder import LLMCassetteRecorder
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import google.genai # noqa: F401 – presence check
|
|
29
|
+
|
|
30
|
+
_genai_available = True # pragma: no cover
|
|
31
|
+
except ImportError: # pragma: no cover
|
|
32
|
+
_genai_available = False
|
|
33
|
+
|
|
34
|
+
_PATCH_TARGET = "google.genai.models.Models.generate_content"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ensure_genai_installed() -> None:
|
|
38
|
+
"""Raise a clear error when the google-genai package is missing."""
|
|
39
|
+
if not _genai_available: # pragma: no cover
|
|
40
|
+
raise ImportError(
|
|
41
|
+
"The google-genai package is required to use GeminiAdapter. "
|
|
42
|
+
"Install it with: pip install agentverify[gemini]"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Lightweight response objects used by denormalize_response so that callers
|
|
48
|
+
# can access ``response.candidates[0].content.parts`` etc. without needing a
|
|
49
|
+
# real Gemini SDK response instance.
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _UsageMetadata:
|
|
54
|
+
"""Minimal stand-in for Gemini ``UsageMetadata``."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, prompt_token_count: int, candidates_token_count: int) -> None:
|
|
57
|
+
self.prompt_token_count = prompt_token_count
|
|
58
|
+
self.candidates_token_count = candidates_token_count
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _FunctionCall:
|
|
62
|
+
"""Minimal stand-in for Gemini ``FunctionCall``."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, name: str, args: dict[str, Any]) -> None:
|
|
65
|
+
self.name = name
|
|
66
|
+
self.args = args
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Part:
|
|
70
|
+
"""Minimal stand-in for Gemini ``Part``.
|
|
71
|
+
|
|
72
|
+
A part has either ``text`` or ``function_call``, but not both.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
text: str | None = None,
|
|
78
|
+
function_call: _FunctionCall | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.text = text
|
|
81
|
+
self.function_call = function_call
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _Content:
|
|
85
|
+
"""Minimal stand-in for Gemini ``Content``."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, parts: list[_Part], role: str = "model") -> None:
|
|
88
|
+
self.parts = parts
|
|
89
|
+
self.role = role
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _Candidate:
|
|
93
|
+
"""Minimal stand-in for Gemini ``Candidate``."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, content: _Content, finish_reason: str = "STOP") -> None:
|
|
96
|
+
self.content = content
|
|
97
|
+
self.finish_reason = finish_reason
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _GenerateContentResponse:
|
|
101
|
+
"""Minimal stand-in for Gemini ``GenerateContentResponse``."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
candidates: list[_Candidate],
|
|
106
|
+
usage_metadata: _UsageMetadata | None = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.candidates = candidates
|
|
109
|
+
self.usage_metadata = usage_metadata
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def text(self) -> str | None:
|
|
113
|
+
"""Return concatenated text from all text parts, or None."""
|
|
114
|
+
texts: list[str] = []
|
|
115
|
+
if self.candidates:
|
|
116
|
+
for part in self.candidates[0].content.parts:
|
|
117
|
+
if part.text is not None:
|
|
118
|
+
texts.append(part.text)
|
|
119
|
+
return "".join(texts) if texts else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Adapter implementation
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class GeminiAdapter(LLMProviderAdapter):
|
|
128
|
+
"""Adapter for the Google Gemini SDK (``google-genai``)."""
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def name(self) -> str:
|
|
132
|
+
return "gemini"
|
|
133
|
+
|
|
134
|
+
# -- normalisation -------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def normalize_request(self, raw_request: dict[str, Any]) -> NormalizedRequest:
|
|
137
|
+
"""Convert Gemini ``generate_content()`` kwargs to *NormalizedRequest*.
|
|
138
|
+
|
|
139
|
+
Gemini uses ``contents`` (str or list) for messages, ``model`` for the
|
|
140
|
+
model name, and ``config`` (a ``GenerateContentConfig``) for tools and
|
|
141
|
+
other parameters.
|
|
142
|
+
"""
|
|
143
|
+
# Messages: contents can be a plain string or a list of messages
|
|
144
|
+
raw_contents = raw_request.get("contents", [])
|
|
145
|
+
if isinstance(raw_contents, str):
|
|
146
|
+
messages: list[dict[str, Any]] = [{"role": "user", "content": raw_contents}]
|
|
147
|
+
elif isinstance(raw_contents, list):
|
|
148
|
+
messages = []
|
|
149
|
+
for item in raw_contents:
|
|
150
|
+
if isinstance(item, str):
|
|
151
|
+
messages.append({"role": "user", "content": item})
|
|
152
|
+
elif isinstance(item, dict):
|
|
153
|
+
messages.append(item)
|
|
154
|
+
else:
|
|
155
|
+
# SDK Content objects — convert to dict representation
|
|
156
|
+
messages.append({"role": getattr(item, "role", "user"), "content": str(item)})
|
|
157
|
+
else:
|
|
158
|
+
messages = []
|
|
159
|
+
|
|
160
|
+
model = raw_request.get("model", "")
|
|
161
|
+
|
|
162
|
+
# Extract tools and parameters from config
|
|
163
|
+
config = raw_request.get("config")
|
|
164
|
+
tools: list[dict[str, Any]] | None = None
|
|
165
|
+
parameters: dict[str, Any] = {}
|
|
166
|
+
|
|
167
|
+
if config is not None:
|
|
168
|
+
# config can be a dict or a GenerateContentConfig object
|
|
169
|
+
if isinstance(config, dict):
|
|
170
|
+
config_dict = config
|
|
171
|
+
else:
|
|
172
|
+
# SDK object — extract attributes
|
|
173
|
+
config_dict = {}
|
|
174
|
+
for attr in ("temperature", "top_p", "top_k", "max_output_tokens",
|
|
175
|
+
"stop_sequences", "candidate_count", "response_mime_type"):
|
|
176
|
+
val = getattr(config, attr, None)
|
|
177
|
+
if val is not None:
|
|
178
|
+
config_dict[attr] = val
|
|
179
|
+
raw_tools = getattr(config, "tools", None)
|
|
180
|
+
if raw_tools is not None:
|
|
181
|
+
config_dict["tools"] = raw_tools
|
|
182
|
+
|
|
183
|
+
# Extract tools from config
|
|
184
|
+
raw_tools = config_dict.pop("tools", None)
|
|
185
|
+
if raw_tools is not None:
|
|
186
|
+
tools = []
|
|
187
|
+
for t in raw_tools:
|
|
188
|
+
if isinstance(t, dict):
|
|
189
|
+
# Dict-based tool definition
|
|
190
|
+
func_decls = t.get("function_declarations", [])
|
|
191
|
+
for fd in func_decls:
|
|
192
|
+
tools.append({
|
|
193
|
+
"name": fd.get("name", ""),
|
|
194
|
+
"parameters": fd.get("parameters", {}),
|
|
195
|
+
})
|
|
196
|
+
else:
|
|
197
|
+
# SDK Tool object
|
|
198
|
+
func_decls = getattr(t, "function_declarations", []) or []
|
|
199
|
+
for fd in func_decls:
|
|
200
|
+
if isinstance(fd, dict):
|
|
201
|
+
tools.append({
|
|
202
|
+
"name": fd.get("name", ""),
|
|
203
|
+
"parameters": fd.get("parameters", {}),
|
|
204
|
+
})
|
|
205
|
+
else:
|
|
206
|
+
tools.append({
|
|
207
|
+
"name": getattr(fd, "name", ""),
|
|
208
|
+
"parameters": getattr(fd, "parameters", {}),
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
# Remaining config fields go into parameters
|
|
212
|
+
if config_dict:
|
|
213
|
+
parameters = config_dict
|
|
214
|
+
|
|
215
|
+
# Extra top-level keys (excluding reserved ones)
|
|
216
|
+
_reserved = {"contents", "model", "config"}
|
|
217
|
+
for k, v in raw_request.items():
|
|
218
|
+
if k not in _reserved:
|
|
219
|
+
parameters[k] = v
|
|
220
|
+
|
|
221
|
+
return NormalizedRequest(
|
|
222
|
+
messages=messages,
|
|
223
|
+
model=model,
|
|
224
|
+
tools=tools if tools else None,
|
|
225
|
+
parameters=parameters,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def normalize_response(self, raw_response: Any) -> NormalizedResponse:
|
|
229
|
+
"""Convert a Gemini ``GenerateContentResponse`` to *NormalizedResponse*.
|
|
230
|
+
|
|
231
|
+
Gemini responses have ``candidates[0].content.parts`` which can contain
|
|
232
|
+
``Part(text=...)`` or ``Part(function_call=FunctionCall(name=..., args=...))``.
|
|
233
|
+
"""
|
|
234
|
+
content: str | None = None
|
|
235
|
+
tool_calls: list[dict[str, Any]] | None = None
|
|
236
|
+
|
|
237
|
+
if raw_response.candidates:
|
|
238
|
+
parts = raw_response.candidates[0].content.parts
|
|
239
|
+
for part in parts:
|
|
240
|
+
# Text part
|
|
241
|
+
if getattr(part, "text", None) is not None:
|
|
242
|
+
if content is None:
|
|
243
|
+
content = part.text
|
|
244
|
+
else:
|
|
245
|
+
content += part.text
|
|
246
|
+
|
|
247
|
+
# Function call part
|
|
248
|
+
fc = getattr(part, "function_call", None)
|
|
249
|
+
if fc is not None:
|
|
250
|
+
if tool_calls is None:
|
|
251
|
+
tool_calls = []
|
|
252
|
+
args = getattr(fc, "args", {}) or {}
|
|
253
|
+
# Convert args dict to JSON string for consistency
|
|
254
|
+
if isinstance(args, dict):
|
|
255
|
+
args_str = json.dumps(args)
|
|
256
|
+
else:
|
|
257
|
+
args_str = json.dumps(dict(args))
|
|
258
|
+
tool_calls.append({
|
|
259
|
+
"name": getattr(fc, "name", ""),
|
|
260
|
+
"arguments": args_str,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
# Token usage
|
|
264
|
+
token_usage: TokenUsage | None = None
|
|
265
|
+
usage = getattr(raw_response, "usage_metadata", None)
|
|
266
|
+
if usage is not None:
|
|
267
|
+
token_usage = TokenUsage(
|
|
268
|
+
input_tokens=getattr(usage, "prompt_token_count", 0),
|
|
269
|
+
output_tokens=getattr(usage, "candidates_token_count", 0),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return NormalizedResponse(
|
|
273
|
+
content=content,
|
|
274
|
+
tool_calls=tool_calls,
|
|
275
|
+
token_usage=token_usage,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def denormalize_response(self, normalized: NormalizedResponse) -> Any:
|
|
279
|
+
"""Build a lightweight object that mimics a Gemini ``GenerateContentResponse``."""
|
|
280
|
+
parts: list[_Part] = []
|
|
281
|
+
|
|
282
|
+
if normalized.content is not None:
|
|
283
|
+
parts.append(_Part(text=normalized.content))
|
|
284
|
+
|
|
285
|
+
if normalized.tool_calls:
|
|
286
|
+
for tc in normalized.tool_calls:
|
|
287
|
+
arguments = tc["arguments"]
|
|
288
|
+
if isinstance(arguments, str):
|
|
289
|
+
try:
|
|
290
|
+
arguments = json.loads(arguments)
|
|
291
|
+
except (json.JSONDecodeError, ValueError):
|
|
292
|
+
arguments = {}
|
|
293
|
+
parts.append(
|
|
294
|
+
_Part(function_call=_FunctionCall(name=tc["name"], args=arguments))
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
finish_reason = "FUNCTION_CALL" if normalized.tool_calls else "STOP"
|
|
298
|
+
candidate = _Candidate(
|
|
299
|
+
content=_Content(parts=parts, role="model"),
|
|
300
|
+
finish_reason=finish_reason,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
usage: _UsageMetadata | None = None
|
|
304
|
+
if normalized.token_usage is not None:
|
|
305
|
+
usage = _UsageMetadata(
|
|
306
|
+
prompt_token_count=normalized.token_usage.input_tokens,
|
|
307
|
+
candidates_token_count=normalized.token_usage.output_tokens,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return _GenerateContentResponse(
|
|
311
|
+
candidates=[candidate],
|
|
312
|
+
usage_metadata=usage,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# -- monkey-patching -----------------------------------------------------
|
|
316
|
+
|
|
317
|
+
@contextmanager
|
|
318
|
+
def patch(self, recorder: LLMCassetteRecorder) -> Generator[None, None, None]:
|
|
319
|
+
"""Monkey-patch ``google.genai.models.Models.generate_content``.
|
|
320
|
+
|
|
321
|
+
In **record** mode the real API is called, the response is normalised,
|
|
322
|
+
recorded into the cassette, and the original response is returned.
|
|
323
|
+
|
|
324
|
+
In **replay** mode the request is normalised, looked up in the
|
|
325
|
+
cassette, denormalised, and returned without calling the real API.
|
|
326
|
+
"""
|
|
327
|
+
_ensure_genai_installed()
|
|
328
|
+
|
|
329
|
+
adapter = self
|
|
330
|
+
|
|
331
|
+
import google.genai.models
|
|
332
|
+
|
|
333
|
+
original_fn = google.genai.models.Models.generate_content
|
|
334
|
+
|
|
335
|
+
def _patched_generate_content(self_client: Any, **kwargs: Any) -> Any:
|
|
336
|
+
"""Intercept generate_content calls."""
|
|
337
|
+
from agentverify.cassette.recorder import CassetteMode, OnMissingRequest
|
|
338
|
+
|
|
339
|
+
norm_req = adapter.normalize_request(kwargs)
|
|
340
|
+
|
|
341
|
+
if recorder.mode in (CassetteMode.RECORD, CassetteMode.AUTO):
|
|
342
|
+
response = original_fn(self_client, **kwargs)
|
|
343
|
+
norm_resp = adapter.normalize_response(response)
|
|
344
|
+
recorder.record(norm_req, norm_resp)
|
|
345
|
+
return response
|
|
346
|
+
|
|
347
|
+
# REPLAY mode
|
|
348
|
+
norm_resp = recorder.lookup(norm_req)
|
|
349
|
+
if norm_resp is None:
|
|
350
|
+
if recorder.on_missing == OnMissingRequest.FALLBACK:
|
|
351
|
+
response = original_fn(self_client, **kwargs)
|
|
352
|
+
norm_resp = adapter.normalize_response(response)
|
|
353
|
+
recorder.record(norm_req, norm_resp)
|
|
354
|
+
return response
|
|
355
|
+
from agentverify.errors import CassetteMissingRequestError
|
|
356
|
+
|
|
357
|
+
raise CassetteMissingRequestError(
|
|
358
|
+
f"No matching cassette entry for request: model={norm_req.model}"
|
|
359
|
+
)
|
|
360
|
+
return adapter.denormalize_response(norm_resp)
|
|
361
|
+
|
|
362
|
+
with patch(_PATCH_TARGET, new=_patched_generate_content):
|
|
363
|
+
yield
|