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.
@@ -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