brooder 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.
brooder/errors.py ADDED
@@ -0,0 +1,31 @@
1
+ """Typed, user-facing exceptions.
2
+
3
+ Anything raised as a ``BrooderError`` is considered safe to print to the user (no stack
4
+ trace). The CLI's single error boundary (``cli.main``) catches these and exits cleanly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class BrooderError(Exception):
11
+ """Base class for expected, user-facing Brooder errors."""
12
+
13
+
14
+ class ScriptNotFoundError(BrooderError):
15
+ """The target agent script does not exist."""
16
+
17
+
18
+ class CorruptRecordError(BrooderError):
19
+ """A stored baseline or run file could not be parsed as a Brooder record."""
20
+
21
+
22
+ class ConfigError(BrooderError):
23
+ """brooder.yaml is present but invalid."""
24
+
25
+
26
+ class RunawayError(Exception):
27
+ """Internal control-flow signal: a run exceeded ``trajectory.max_steps`` and was aborted.
28
+
29
+ Deliberately **not** a :class:`BrooderError`: it is always caught by the ``@record`` wrapper
30
+ (which turns it into a recorded ``runaway`` run) and must never reach the CLI error boundary.
31
+ """
@@ -0,0 +1,75 @@
1
+ """Provider auto-capture.
2
+
3
+ Wrap an LLM client once and Brooder records the model's tool-call decisions automatically —
4
+ no manual :func:`brooder.tool_call`. Supported: OpenAI, Azure OpenAI, Anthropic, AWS Bedrock,
5
+ and Google (Gemini / Vertex).
6
+
7
+ import brooder, openai
8
+ client = brooder.instrument(openai.OpenAI())
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+ from typing import Any, Optional
15
+
16
+ from ..errors import BrooderError
17
+ from . import anthropic, bedrock, google, openai
18
+
19
+ # User-facing provider name -> internal adapter key.
20
+ _ALIASES = {
21
+ "openai": "openai",
22
+ "azure": "openai",
23
+ "anthropic": "anthropic",
24
+ "bedrock": "bedrock",
25
+ "aws": "bedrock",
26
+ "google": "google",
27
+ "gcp": "google",
28
+ "vertex": "google",
29
+ "gemini": "google",
30
+ }
31
+
32
+ # Adapter key -> its instrument function.
33
+ _ADAPTERS: dict[str, Callable[..., Any]] = {
34
+ "openai": openai.instrument,
35
+ "anthropic": anthropic.instrument,
36
+ "bedrock": bedrock.instrument,
37
+ "google": google.instrument,
38
+ }
39
+
40
+
41
+ def _detect(client: Any) -> str:
42
+ """Infer the provider key from a client, or raise if it can't be determined."""
43
+ root = (type(client).__module__ or "").split(".")[0]
44
+ if root == "openai":
45
+ return "openai"
46
+ if root == "anthropic":
47
+ return "anthropic"
48
+ if root in ("botocore", "boto3") or hasattr(client, "converse"):
49
+ return "bedrock"
50
+ if root in ("google", "vertexai") or hasattr(client, "generate_content"):
51
+ return "google"
52
+ raise BrooderError("could not detect provider from client; pass provider=... explicitly")
53
+
54
+
55
+ def instrument(client: Any, provider: Optional[str] = None, capture_content: bool = False) -> Any:
56
+ """Instrument an LLM client so Brooder auto-records the model's tool calls.
57
+
58
+ Args:
59
+ client: A provider SDK client — OpenAI/AzureOpenAI, Anthropic, a boto3 Bedrock-runtime
60
+ client, or a Google ``GenerativeModel``.
61
+ provider: Force a provider (``"openai"``, ``"azure"``, ``"anthropic"``, ``"bedrock"``/
62
+ ``"aws"``, ``"google"``/``"gcp"``/``"vertex"``/``"gemini"``). Auto-detected if omitted.
63
+ capture_content: Also record assistant text, not just tool calls.
64
+
65
+ Returns:
66
+ The same client, with the relevant method patched in place.
67
+
68
+ Raises:
69
+ BrooderError: If the provider cannot be resolved.
70
+ """
71
+ key = provider.lower() if provider else _detect(client)
72
+ resolved = _ALIASES.get(key)
73
+ if resolved is None:
74
+ raise BrooderError(f"unknown provider: {provider!r}")
75
+ return _ADAPTERS[resolved](client, capture_content=capture_content)
@@ -0,0 +1,46 @@
1
+ """Anthropic auto-capture.
2
+
3
+ Wraps ``client.messages.create`` and normalizes the content-block list (``text`` and
4
+ ``tool_use`` blocks). Also covers the Anthropic-on-Bedrock / Vertex clients from the same SDK.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Optional
10
+
11
+ from .base import NormalizedCall, ToolRequest, as_dict, get, wrap
12
+
13
+
14
+ def _normalize(_kwargs: dict[str, Any], response: Any) -> Optional[NormalizedCall]:
15
+ texts: list[str] = []
16
+ tool_calls: list[ToolRequest] = []
17
+ for block in get(response, "content") or []:
18
+ kind = get(block, "type")
19
+ if kind == "text":
20
+ text = get(block, "text")
21
+ if text:
22
+ texts.append(text)
23
+ elif kind == "tool_use":
24
+ tool_calls.append(
25
+ ToolRequest(name=get(block, "name") or "", arguments=as_dict(get(block, "input")))
26
+ )
27
+ return NormalizedCall(
28
+ provider="anthropic",
29
+ model=get(response, "model"),
30
+ content="".join(texts) or None,
31
+ tool_calls=tool_calls,
32
+ )
33
+
34
+
35
+ def instrument(client: Any, capture_content: bool = False) -> Any:
36
+ """Instrument an Anthropic client's ``messages.create``.
37
+
38
+ Args:
39
+ client: An ``Anthropic`` (or ``AnthropicBedrock`` / ``AnthropicVertex``) client.
40
+ capture_content: Also record assistant text, not just tool calls.
41
+
42
+ Returns:
43
+ The same client, patched in place.
44
+ """
45
+ wrap(client.messages, "create", _normalize, capture_content)
46
+ return client
@@ -0,0 +1,170 @@
1
+ """Provider-agnostic core for auto-capture.
2
+
3
+ Each provider adapter turns its SDK's response into a :class:`NormalizedCall`, and :func:`wrap`
4
+ patches the SDK method so every call is normalized and recorded into the active run. Capture is
5
+ best-effort: if normalization ever fails it is logged at debug level and the user's original call
6
+ result is returned unchanged — instrumentation must never break the app it observes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import inspect
13
+ import json
14
+ import weakref
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Optional
18
+
19
+ from .. import recorder
20
+ from ..errors import RunawayError
21
+ from ..log import get_logger
22
+
23
+ _log = get_logger()
24
+
25
+ # Methods we've already wrapped, so instrumenting twice is a no-op.
26
+ _wrapped: weakref.WeakSet[Any] = weakref.WeakSet()
27
+
28
+
29
+ @dataclass
30
+ class ToolRequest:
31
+ """A tool/function the model asked to call, in provider-neutral form."""
32
+
33
+ name: str
34
+ arguments: dict[str, Any] = field(default_factory=dict)
35
+
36
+
37
+ @dataclass
38
+ class NormalizedCall:
39
+ """A single model call normalized across providers."""
40
+
41
+ provider: str
42
+ model: Optional[str] = None
43
+ content: Optional[str] = None
44
+ tool_calls: list[ToolRequest] = field(default_factory=list)
45
+
46
+
47
+ # A capture function receives the call kwargs and the raw response and returns a NormalizedCall
48
+ # (or None to skip). It must not raise for expected shapes.
49
+ CaptureFn = Callable[[dict[str, Any], Any], Optional[NormalizedCall]]
50
+
51
+
52
+ def record_call(call: NormalizedCall, capture_content: bool = False) -> None:
53
+ """Record a normalized model call into the active run's trajectory.
54
+
55
+ Records a ``TURN`` step for the call (so turn counts are captured), then a tool-call step for
56
+ each tool the model requested (matching manual :func:`brooder.tool_call` semantics), so
57
+ behavioral regressions in tool selection are caught. The model name is deliberately *not*
58
+ recorded, so switching models is not itself a diff.
59
+
60
+ Args:
61
+ call: The normalized call.
62
+ capture_content: If ``True``, also record the assistant's text content (only when it made
63
+ no tool calls). Off by default to keep diffs focused on tool decisions.
64
+ """
65
+ recorder.turn({"provider": call.provider})
66
+ for tool in call.tool_calls:
67
+ recorder.tool_call(tool.name, tool.arguments)
68
+ if capture_content and call.content is not None and not call.tool_calls:
69
+ recorder.tool_call(f"llm:{call.provider}", {}, result=call.content)
70
+
71
+
72
+ def _capture_into_run(
73
+ holder: Any,
74
+ attr: str,
75
+ capture: CaptureFn,
76
+ kwargs: dict[str, Any],
77
+ response: Any,
78
+ capture_content: bool,
79
+ ) -> None:
80
+ """Normalize ``response`` and record it into the active run — best-effort.
81
+
82
+ Shared by the sync and async wrappers. A capture failure is logged and swallowed so
83
+ instrumentation never breaks the user's call; only :class:`RunawayError` (the ``max_steps``
84
+ guardrail) is allowed to propagate.
85
+ """
86
+ try:
87
+ call = capture(kwargs, response)
88
+ if call is not None:
89
+ record_call(call, capture_content=capture_content)
90
+ except RunawayError:
91
+ # The max_steps guardrail must abort the run, not be swallowed as a capture failure.
92
+ raise
93
+ except Exception:
94
+ # Capture is best-effort and must never break the user's call.
95
+ _log.debug("brooder: capture failed for %s.%s", type(holder).__name__, attr, exc_info=True)
96
+
97
+
98
+ def wrap(holder: Any, attr: str, capture: CaptureFn, capture_content: bool = False) -> None:
99
+ """Patch ``holder.attr`` so each call is normalized and recorded.
100
+
101
+ Works for both sync and ``async def`` methods: an async method (e.g. ``AsyncOpenAI``'s
102
+ ``chat.completions.create``) is wrapped with a coroutine that awaits the original before
103
+ recording. Idempotent: wrapping an already-wrapped method is a no-op.
104
+
105
+ Args:
106
+ holder: The object owning the method (e.g. ``client.chat.completions``).
107
+ attr: The method name to wrap (e.g. ``"create"``).
108
+ capture: Turns ``(kwargs, response)`` into a :class:`NormalizedCall`.
109
+ capture_content: Forwarded to :func:`record_call`.
110
+ """
111
+ original = getattr(holder, attr)
112
+ if original in _wrapped:
113
+ return
114
+
115
+ wrapper: Callable[..., Any]
116
+ if inspect.iscoroutinefunction(original):
117
+
118
+ @functools.wraps(original)
119
+ async def _async_wrapper(*args: Any, **kwargs: Any) -> Any:
120
+ response = await original(*args, **kwargs)
121
+ _capture_into_run(holder, attr, capture, kwargs, response, capture_content)
122
+ return response
123
+
124
+ wrapper = _async_wrapper
125
+ else:
126
+
127
+ @functools.wraps(original)
128
+ def _sync_wrapper(*args: Any, **kwargs: Any) -> Any:
129
+ response = original(*args, **kwargs)
130
+ _capture_into_run(holder, attr, capture, kwargs, response, capture_content)
131
+ return response
132
+
133
+ wrapper = _sync_wrapper
134
+
135
+ _wrapped.add(wrapper)
136
+ setattr(holder, attr, wrapper)
137
+
138
+
139
+ def get(obj: Any, key: str, default: Any = None) -> Any:
140
+ """Read ``key`` from an object by attribute or a dict by item, tolerating either shape."""
141
+ if obj is None:
142
+ return default
143
+ if isinstance(obj, dict):
144
+ return obj.get(key, default)
145
+ return getattr(obj, key, default)
146
+
147
+
148
+ def parse_json(raw: Any) -> dict[str, Any]:
149
+ """Coerce a tool-arguments payload (JSON string, dict, or None) into a dict."""
150
+ if isinstance(raw, dict):
151
+ return dict(raw)
152
+ if isinstance(raw, str):
153
+ try:
154
+ parsed = json.loads(raw)
155
+ except json.JSONDecodeError:
156
+ return {"_raw": raw}
157
+ return parsed if isinstance(parsed, dict) else {"_value": parsed}
158
+ return as_dict(raw)
159
+
160
+
161
+ def as_dict(value: Any) -> dict[str, Any]:
162
+ """Best-effort conversion of a mapping-like value into a plain dict."""
163
+ if value is None:
164
+ return {}
165
+ if isinstance(value, dict):
166
+ return dict(value)
167
+ try:
168
+ return dict(value)
169
+ except (TypeError, ValueError):
170
+ return {"_value": str(value)}
@@ -0,0 +1,49 @@
1
+ """AWS Bedrock auto-capture.
2
+
3
+ Wraps the boto3 ``bedrock-runtime`` client's ``converse`` method. The model id comes from the
4
+ call kwargs (``modelId``); content and tool use come from the ``output.message.content`` blocks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Optional
10
+
11
+ from .base import NormalizedCall, ToolRequest, as_dict, get, wrap
12
+
13
+
14
+ def _normalize(kwargs: dict[str, Any], response: Any) -> Optional[NormalizedCall]:
15
+ message = get(get(response, "output"), "message")
16
+ texts: list[str] = []
17
+ tool_calls: list[ToolRequest] = []
18
+ for block in get(message, "content") or []:
19
+ text = get(block, "text")
20
+ if text is not None:
21
+ texts.append(text)
22
+ tool_use = get(block, "toolUse")
23
+ if tool_use:
24
+ tool_calls.append(
25
+ ToolRequest(
26
+ name=get(tool_use, "name") or "",
27
+ arguments=as_dict(get(tool_use, "input")),
28
+ )
29
+ )
30
+ return NormalizedCall(
31
+ provider="bedrock",
32
+ model=get(kwargs, "modelId"),
33
+ content="".join(texts) or None,
34
+ tool_calls=tool_calls,
35
+ )
36
+
37
+
38
+ def instrument(client: Any, capture_content: bool = False) -> Any:
39
+ """Instrument a boto3 ``bedrock-runtime`` client's ``converse``.
40
+
41
+ Args:
42
+ client: A boto3 client created with ``boto3.client("bedrock-runtime")``.
43
+ capture_content: Also record assistant text, not just tool calls.
44
+
45
+ Returns:
46
+ The same client, patched in place.
47
+ """
48
+ wrap(client, "converse", _normalize, capture_content)
49
+ return client
@@ -0,0 +1,164 @@
1
+ """Claude Agent SDK capture — trajectories from the SDK's hooks.
2
+
3
+ Register Brooder's hooks with the Claude Agent SDK and it records the agent's tool trajectory
4
+ automatically — no manual ``tool_call``:
5
+
6
+ import brooder
7
+ from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, ResultMessage
8
+
9
+ options = ClaudeAgentOptions(hooks=brooder.claude_agent_hooks(agent="support-agent"))
10
+ async with ClaudeSDKClient(options=options) as client:
11
+ await client.query(prompt)
12
+ async for message in client.receive_response():
13
+ if isinstance(message, ResultMessage):
14
+ brooder.integrations.claude_agent.record_output(message.session_id, message.result)
15
+
16
+ **How hooks map to steps** (verified against the Python SDK):
17
+
18
+ - ``UserPromptSubmit`` → open a run keyed by ``session_id``; the prompt becomes the case identity.
19
+ - ``PostToolUse`` / ``PostToolUseFailure`` → a ``TOOL`` step (name, input, and the tool response).
20
+ - ``Stop`` → a ``FINAL`` step and save.
21
+
22
+ **SDK constraints this works around.** The Python SDK exposes no ``SessionStart`` / ``SessionEnd``
23
+ hooks (those are settings-file only), so the prompt/stop cycle delimits a run — each user turn in a
24
+ session is one case. And ``Stop`` does not carry the final assistant text (it lives in the
25
+ ``ResultMessage`` stream), so the tool *trajectory* — the core diff signal — is always captured, but
26
+ the final *answer* is only recorded if you feed it via :func:`record_output`. Turn counts aren't
27
+ exposed by hooks, so no ``TURN`` steps are fabricated.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import threading
33
+ from typing import Any, Optional
34
+
35
+ from .. import recorder
36
+ from ..errors import BrooderError
37
+ from ..log import get_logger
38
+ from .base import as_dict
39
+
40
+ _log = get_logger()
41
+
42
+ # Final answers recorded out-of-band (hooks don't expose them), keyed by session id.
43
+ _outputs: dict[str, Any] = {}
44
+ _outputs_lock = threading.Lock()
45
+
46
+
47
+ def record_output(session_id: str, output: Any) -> None:
48
+ """Record the agent's final answer for a session so its run gets a populated ``FINAL`` step.
49
+
50
+ The Claude Agent SDK's ``Stop`` hook does not carry the final assistant text — it comes from the
51
+ ``ResultMessage`` in the message stream. Call this from your loop (with ``message.result``) to
52
+ make the final output diffable. Optional: the tool trajectory is captured from hooks regardless.
53
+
54
+ Args:
55
+ session_id: The session id from the message / hook payload.
56
+ output: The agent's final answer.
57
+ """
58
+ with _outputs_lock:
59
+ _outputs[session_id] = output
60
+
61
+
62
+ def _pop_output(session_id: str) -> Any:
63
+ """Take and clear any out-of-band output recorded for ``session_id``."""
64
+ with _outputs_lock:
65
+ return _outputs.pop(session_id, None)
66
+
67
+
68
+ def _reset() -> None:
69
+ """Clear out-of-band output state (used by tests)."""
70
+ with _outputs_lock:
71
+ _outputs.clear()
72
+
73
+
74
+ class _Capture:
75
+ """Builds the Brooder hook callbacks; each payload carries the ``session_id`` to key on."""
76
+
77
+ def __init__(self, agent: Optional[str]) -> None:
78
+ self._agent = agent
79
+
80
+ def _handle(self, session_id: str) -> recorder.RunHandle:
81
+ """Get the session's open run, opening one lazily if a hook fired before the prompt."""
82
+ handle = recorder.get_run(session_id)
83
+ if handle is None:
84
+ handle = recorder.open_run(self._agent or "claude-agent", external_id=session_id)
85
+ return handle
86
+
87
+ async def _on_user_prompt(
88
+ self, input_data: dict[str, Any], tool_use_id: Optional[str], context: Any
89
+ ) -> dict[str, Any]:
90
+ try:
91
+ session_id = input_data.get("session_id")
92
+ if session_id is not None:
93
+ stale = recorder.get_run(session_id)
94
+ if stale is not None:
95
+ stale.finish(_pop_output(session_id)) # prior turn never hit Stop; close it
96
+ handle = recorder.open_run(self._agent or "claude-agent", external_id=session_id)
97
+ prompt = input_data.get("prompt")
98
+ if prompt is not None:
99
+ handle.set_inputs(prompt)
100
+ except Exception: # capture must never break the agent
101
+ _log.debug("brooder: claude-agent UserPromptSubmit capture failed", exc_info=True)
102
+ return {}
103
+
104
+ async def _on_tool(
105
+ self, input_data: dict[str, Any], tool_use_id: Optional[str], context: Any
106
+ ) -> dict[str, Any]:
107
+ try:
108
+ session_id = input_data.get("session_id")
109
+ if session_id is not None:
110
+ self._handle(session_id).tool_call(
111
+ input_data.get("tool_name") or "tool",
112
+ as_dict(input_data.get("tool_input")),
113
+ result=input_data.get("tool_response"),
114
+ )
115
+ except Exception:
116
+ _log.debug("brooder: claude-agent PostToolUse capture failed", exc_info=True)
117
+ return {}
118
+
119
+ async def _on_stop(
120
+ self, input_data: dict[str, Any], tool_use_id: Optional[str], context: Any
121
+ ) -> dict[str, Any]:
122
+ try:
123
+ session_id = input_data.get("session_id")
124
+ if session_id is not None:
125
+ handle = recorder.get_run(session_id)
126
+ if handle is not None:
127
+ handle.final(_pop_output(session_id)) # Stop = the agent produced an answer
128
+ handle.finish()
129
+ except Exception:
130
+ _log.debug("brooder: claude-agent Stop capture failed", exc_info=True)
131
+ return {}
132
+
133
+ def callbacks(self) -> dict[str, list[Any]]:
134
+ """Return ``{event_name: [callback]}`` for the events Brooder captures."""
135
+ return {
136
+ "UserPromptSubmit": [self._on_user_prompt],
137
+ "PostToolUse": [self._on_tool],
138
+ "PostToolUseFailure": [self._on_tool],
139
+ "Stop": [self._on_stop],
140
+ }
141
+
142
+
143
+ def claude_agent_hooks(agent: Optional[str] = None) -> dict[str, list[Any]]:
144
+ """Build the hooks mapping to pass to ``ClaudeAgentOptions(hooks=...)``.
145
+
146
+ Records the agent's tool trajectory into Brooder runs (see the module docstring for details).
147
+
148
+ Args:
149
+ agent: Logical agent name used to group baselines (defaults to ``"claude-agent"``).
150
+
151
+ Returns:
152
+ A ``{event_name: [HookMatcher(...)]}`` mapping for ``ClaudeAgentOptions``.
153
+
154
+ Raises:
155
+ BrooderError: If the Claude Agent SDK is not installed.
156
+ """
157
+ try:
158
+ from claude_agent_sdk import HookMatcher
159
+ except ImportError as exc:
160
+ raise BrooderError(
161
+ "claude_agent_hooks() needs the Claude Agent SDK — `pip install claude-agent-sdk`"
162
+ ) from exc
163
+ capture = _Capture(agent)
164
+ return {event: [HookMatcher(hooks=cbs)] for event, cbs in capture.callbacks().items()}
@@ -0,0 +1,61 @@
1
+ """Google Gemini / Vertex AI auto-capture.
2
+
3
+ Wraps a ``GenerativeModel``'s ``generate_content``. The model name is read from the model object
4
+ (``model_name``); content and function calls come from the first candidate's parts. Covers both
5
+ the ``google-generativeai`` (Gemini API) and ``vertexai`` SDKs, which share this response shape.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any, Optional
12
+
13
+ from .base import NormalizedCall, ToolRequest, as_dict, get, wrap
14
+
15
+ CaptureFn = Callable[[dict[str, Any], Any], Optional[NormalizedCall]]
16
+
17
+
18
+ def _normalizer(model_name: Optional[str]) -> CaptureFn:
19
+ def _normalize(_kwargs: dict[str, Any], response: Any) -> Optional[NormalizedCall]:
20
+ candidates = get(response, "candidates") or []
21
+ content = get(candidates[0], "content") if candidates else None
22
+ texts: list[str] = []
23
+ tool_calls: list[ToolRequest] = []
24
+ for part in get(content, "parts") or []:
25
+ text = get(part, "text")
26
+ if text:
27
+ texts.append(text)
28
+ function_call = get(part, "function_call")
29
+ if function_call:
30
+ tool_calls.append(
31
+ ToolRequest(
32
+ name=get(function_call, "name") or "",
33
+ arguments=as_dict(get(function_call, "args")),
34
+ )
35
+ )
36
+ return NormalizedCall(
37
+ provider="google",
38
+ model=model_name,
39
+ content="".join(texts) or None,
40
+ tool_calls=tool_calls,
41
+ )
42
+
43
+ return _normalize
44
+
45
+
46
+ def instrument(model: Any, capture_content: bool = False) -> Any:
47
+ """Instrument a Google ``GenerativeModel``'s ``generate_content``.
48
+
49
+ Args:
50
+ model: A ``google.generativeai`` or ``vertexai`` ``GenerativeModel`` instance.
51
+ capture_content: Also record assistant text, not just tool calls.
52
+
53
+ Returns:
54
+ The same model, patched in place.
55
+ """
56
+ normalize = _normalizer(get(model, "model_name"))
57
+ wrap(model, "generate_content", normalize, capture_content)
58
+ # Async Gemini/Vertex uses a distinct coroutine method; wrap it too when present.
59
+ if hasattr(model, "generate_content_async"):
60
+ wrap(model, "generate_content_async", normalize, capture_content)
61
+ return model