Skip to content

Commit 06aaf97

Browse files
committed
ref(pydantic-ai): Move pydantic-ai object reads behind an extraction layer
Introduce _extract.py as the module that concentrates reads of pydantic-ai object internals (private attributes, message part classes, version-dependent shapes) behind typed accessors returning plain data structures. Span modules and patches now consume those accessors, collapsing the duplicated message formatters and blob serializers into one implementation. Review-driven fixes folded in: response access in extract_response_model_name is now exception-safe (AgentRunResult.response can raise), token usage reporting goes through the shared record_token_usage helper, model-name resolution for the chat span name matches gen_ai.request.model resolution, and unknown model settings are skipped instead of raising KeyError.
1 parent 8c9231c commit 06aaf97

8 files changed

Lines changed: 585 additions & 501 deletions

File tree

sentry_sdk/integrations/pydantic_ai/_extract.py

Lines changed: 488 additions & 0 deletions
Large diffs are not rendered by default.

sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from sentry_sdk.integrations import DidNotEnable
55

6+
from .._extract import extract_graph_request_data
67
from ..spans import (
78
ai_client_span,
89
update_ai_client_span,
@@ -21,31 +22,6 @@
2122
from pydantic_ai.messages import ModelResponse
2223

2324

24-
def _extract_span_data(node: "Any", ctx: "Any") -> "tuple[list[Any], Any, Any]":
25-
"""Extract common data needed for creating chat spans.
26-
27-
Returns:
28-
Tuple of (messages, model, model_settings)
29-
"""
30-
# Extract model and settings from context
31-
model = None
32-
model_settings = None
33-
if hasattr(ctx, "deps"):
34-
model = getattr(ctx.deps, "model", None)
35-
model_settings = getattr(ctx.deps, "model_settings", None)
36-
37-
# Build full message list: history + current request
38-
messages = []
39-
if hasattr(ctx, "state") and hasattr(ctx.state, "message_history"):
40-
messages.extend(ctx.state.message_history)
41-
42-
current_request = getattr(node, "request", None)
43-
if current_request:
44-
messages.append(current_request)
45-
46-
return messages, model, model_settings
47-
48-
4925
def _patch_graph_nodes() -> None:
5026
"""
5127
Patches the graph node execution to create appropriate spans.
@@ -67,7 +43,7 @@ async def wrapped_model_request_run(self: "Any", ctx: "Any") -> "Any":
6743
if did_stream or cached_result is not None:
6844
return await original_model_request_run(self, ctx)
6945

70-
messages, model, model_settings = _extract_span_data(self, ctx)
46+
messages, model, model_settings = extract_graph_request_data(self, ctx)
7147

7248
with ai_client_span(messages, None, model, model_settings) as span:
7349
result = await original_model_request_run(self, ctx)
@@ -101,7 +77,7 @@ async def wrapped_model_request_stream(self: "Any", ctx: "Any") -> "Any":
10177
yield stream
10278
return
10379

104-
messages, model, model_settings = _extract_span_data(self, ctx)
80+
messages, model, model_settings = extract_graph_request_data(self, ctx)
10581

10682
# Create chat span for streaming request
10783
with ai_client_span(messages, None, model, model_settings) as span:

sentry_sdk/integrations/pydantic_ai/patches/tools.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from sentry_sdk.integrations import DidNotEnable
77
from sentry_sdk.utils import capture_internal_exceptions, reraise
88

9+
from .._extract import extract_tool_call_args
910
from ..spans import execute_tool_span, update_execute_tool_span
1011
from ..utils import _capture_exception, get_current_agent
1112

@@ -52,10 +53,7 @@ async def wrapped_execute_tool_call(
5253
agent = get_current_agent()
5354

5455
if agent and tool:
55-
try:
56-
args_dict = call.args_as_dict()
57-
except Exception:
58-
args_dict = call.args if isinstance(call.args, dict) else {}
56+
args_dict = extract_tool_call_args(call)
5957

6058
# Create execute_tool span
6159
# Nesting is handled by isolation_scope() to ensure proper parent-child relationships
@@ -125,10 +123,7 @@ async def wrapped_call_tool(
125123
agent = get_current_agent()
126124

127125
if agent and tool:
128-
try:
129-
args_dict = call.args_as_dict()
130-
except Exception:
131-
args_dict = call.args if isinstance(call.args, dict) else {}
126+
args_dict = extract_tool_call_args(call)
132127

133128
# Create execute_tool span
134129
# Nesting is handled by isolation_scope() to ensure proper parent-child relationships

sentry_sdk/integrations/pydantic_ai/spans/ai_client.py

Lines changed: 25 additions & 201 deletions
Original file line numberDiff line numberDiff line change
@@ -13,96 +13,31 @@
1313
has_span_streaming_enabled,
1414
should_truncate_gen_ai_input,
1515
)
16-
from sentry_sdk.utils import safe_serialize
1716

17+
from .._extract import (
18+
extract_model_info,
19+
extract_request_messages,
20+
extract_response_parts,
21+
extract_system_instructions,
22+
)
1823
from ..consts import SPAN_ORIGIN
1924
from ..utils import (
20-
_get_model_name,
2125
_set_agent_data,
2226
_set_available_tools,
2327
_set_model_data,
2428
_should_send_prompts,
2529
get_current_agent,
2630
get_is_streaming,
2731
)
28-
from .utils import (
29-
_serialize_binary_content_item,
30-
_serialize_image_url_item,
31-
_set_usage_data,
32-
)
32+
from .utils import _set_usage_data
3333

3434
if TYPE_CHECKING:
35-
from typing import Any, Dict, List, Optional, Union
35+
from typing import Any, Optional, Union
3636

37-
from pydantic_ai.messages import ModelMessage, ModelResponse, SystemPromptPart
37+
from pydantic_ai.messages import ModelResponse
3838

39-
from sentry_sdk import _types
4039
from sentry_sdk.traces import StreamedSpan
4140

42-
try:
43-
from pydantic_ai.messages import (
44-
BaseToolCallPart,
45-
BaseToolReturnPart,
46-
BinaryContent,
47-
ImageUrl,
48-
SystemPromptPart,
49-
TextPart,
50-
ThinkingPart,
51-
UserPromptPart,
52-
)
53-
except ImportError:
54-
# Fallback if these classes are not available
55-
BaseToolCallPart = None # type: ignore[misc,assignment]
56-
BaseToolReturnPart = None # type: ignore[misc,assignment]
57-
SystemPromptPart = None # type: ignore[misc,assignment]
58-
UserPromptPart = None # type: ignore[misc,assignment]
59-
TextPart = None # type: ignore[misc,assignment]
60-
ThinkingPart = None # type: ignore[misc,assignment]
61-
BinaryContent = None # type: ignore[misc,assignment]
62-
ImageUrl = None # type: ignore[misc,assignment]
63-
ThinkingPart = None # type: ignore[misc,assignment]
64-
65-
66-
def _transform_system_instructions(
67-
permanent_instructions: "list[SystemPromptPart]",
68-
current_instructions: "list[str]",
69-
) -> "list[_types.TextPart]":
70-
text_parts: "list[_types.TextPart]" = [
71-
{
72-
"type": "text",
73-
"content": instruction.content,
74-
}
75-
for instruction in permanent_instructions
76-
]
77-
78-
text_parts.extend(
79-
{
80-
"type": "text",
81-
"content": instruction,
82-
}
83-
for instruction in current_instructions
84-
)
85-
86-
return text_parts
87-
88-
89-
def _get_system_instructions(
90-
messages: "list[ModelMessage]",
91-
) -> "tuple[list[SystemPromptPart], list[str]]":
92-
permanent_instructions = []
93-
current_instructions = []
94-
95-
for msg in messages:
96-
if hasattr(msg, "parts"):
97-
for part in msg.parts:
98-
if SystemPromptPart is not None and isinstance(part, SystemPromptPart):
99-
permanent_instructions.append(part)
100-
101-
if hasattr(msg, "instructions") and msg.instructions is not None:
102-
current_instructions.append(msg.instructions)
103-
104-
return permanent_instructions, current_instructions
105-
10641

10742
def _set_input_messages(
10843
span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", messages: "Any"
@@ -114,97 +49,16 @@ def _set_input_messages(
11449
if not messages:
11550
return
11651

117-
permanent_instructions, current_instructions = _get_system_instructions(messages)
118-
if len(permanent_instructions) > 0 or len(current_instructions) > 0:
52+
system_instructions = extract_system_instructions(messages)
53+
if system_instructions:
11954
_set_span_data_attribute(
12055
span,
12156
SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS,
122-
json.dumps(
123-
_transform_system_instructions(
124-
permanent_instructions, current_instructions
125-
)
126-
),
57+
json.dumps(system_instructions),
12758
)
12859

12960
try:
130-
formatted_messages = []
131-
132-
for msg in messages:
133-
if hasattr(msg, "parts"):
134-
for part in msg.parts:
135-
role = "user"
136-
# Use isinstance checks with proper base classes
137-
if SystemPromptPart is not None and isinstance(
138-
part, SystemPromptPart
139-
):
140-
continue
141-
elif (
142-
(TextPart is not None and isinstance(part, TextPart))
143-
or (ThinkingPart is not None and isinstance(part, ThinkingPart))
144-
or (
145-
BaseToolCallPart is not None
146-
and isinstance(part, BaseToolCallPart)
147-
)
148-
):
149-
role = "assistant"
150-
elif BaseToolReturnPart is not None and isinstance(
151-
part, BaseToolReturnPart
152-
):
153-
role = "tool"
154-
155-
content: "List[Dict[str, Any] | str]" = []
156-
tool_calls = None
157-
tool_call_id = None
158-
159-
# Handle ToolCallPart (assistant requesting tool use)
160-
if BaseToolCallPart is not None and isinstance(
161-
part, BaseToolCallPart
162-
):
163-
tool_call_data = {}
164-
if hasattr(part, "tool_name"):
165-
tool_call_data["name"] = part.tool_name
166-
if hasattr(part, "args"):
167-
tool_call_data["arguments"] = safe_serialize(part.args)
168-
if tool_call_data:
169-
tool_calls = [tool_call_data]
170-
# Handle ToolReturnPart (tool result)
171-
elif BaseToolReturnPart is not None and isinstance(
172-
part, BaseToolReturnPart
173-
):
174-
if hasattr(part, "tool_name"):
175-
tool_call_id = part.tool_name
176-
if hasattr(part, "content"):
177-
content.append({"type": "text", "text": str(part.content)})
178-
# Handle regular content
179-
elif hasattr(part, "content"):
180-
if isinstance(part.content, str):
181-
content.append({"type": "text", "text": part.content})
182-
elif isinstance(part.content, list):
183-
for item in part.content:
184-
if isinstance(item, str):
185-
content.append({"type": "text", "text": item})
186-
elif ImageUrl is not None and isinstance(
187-
item, ImageUrl
188-
):
189-
content.append(_serialize_image_url_item(item))
190-
elif BinaryContent is not None and isinstance(
191-
item, BinaryContent
192-
):
193-
content.append(_serialize_binary_content_item(item))
194-
else:
195-
content.append(safe_serialize(item))
196-
else:
197-
content.append({"type": "text", "text": str(part.content)})
198-
# Add message if we have content or tool calls
199-
if content or tool_calls:
200-
message: "Dict[str, Any]" = {"role": role}
201-
if content:
202-
message["content"] = content
203-
if tool_calls:
204-
message["tool_calls"] = tool_calls
205-
if tool_call_id:
206-
message["tool_call_id"] = tool_call_id
207-
formatted_messages.append(message)
61+
formatted_messages = extract_request_messages(messages)
20862

20963
if formatted_messages:
21064
normalized_messages = normalize_message_roles(formatted_messages)
@@ -240,42 +94,13 @@ def _set_output_data(
24094
)
24195

24296
try:
243-
if hasattr(response, "parts"):
244-
parts: "list[Union[_types.TextPart, _types.ReasoningPart, _types.ToolCallPart]]" = []
245-
246-
for part in response.parts:
247-
if (
248-
TextPart is not None
249-
and isinstance(part, TextPart)
250-
and hasattr(part, "content")
251-
):
252-
parts.append({"type": "text", "content": part.content})
253-
254-
elif ThinkingPart is not None and isinstance(part, ThinkingPart):
255-
parts.append(
256-
{
257-
"type": "reasoning",
258-
"content": part.content,
259-
}
260-
)
261-
262-
elif BaseToolCallPart is not None and isinstance(
263-
part, BaseToolCallPart
264-
):
265-
tool_part: "_types.ToolCallPart" = {"type": "tool_call"}
266-
if hasattr(part, "tool_name"):
267-
tool_part["name"] = part.tool_name
268-
if hasattr(part, "args"):
269-
tool_part["arguments"] = safe_serialize(part.args)
270-
parts.append(tool_part)
271-
272-
if parts:
273-
_set_span_data_attribute(
274-
span,
275-
SPANDATA.GEN_AI_OUTPUT_MESSAGES,
276-
json.dumps([{"role": "assistant", "parts": parts}]),
277-
)
278-
97+
parts = extract_response_parts(response)
98+
if parts:
99+
_set_span_data_attribute(
100+
span,
101+
SPANDATA.GEN_AI_OUTPUT_MESSAGES,
102+
json.dumps([{"role": "assistant", "parts": parts}]),
103+
)
279104
except Exception:
280105
# If we fail to format output, just skip it
281106
pass
@@ -292,12 +117,11 @@ def ai_client_span(
292117
model: Model object
293118
model_settings: Model settings
294119
"""
295-
# Determine model name for span name
296-
model_obj = model
297-
if agent and hasattr(agent, "model"):
298-
model_obj = agent.model
299-
300-
model_name = _get_model_name(model_obj) or "unknown"
120+
# Determine model name for span name, resolving the same way as
121+
# _set_model_data so the span name and gen_ai.request.model agree
122+
model_name = (
123+
extract_model_info(model, None, agent or get_current_agent()).name or "unknown"
124+
)
301125

302126
span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options)
303127
if span_streaming:

0 commit comments

Comments
 (0)