From 97834f97623472223f445d827f97aec6ae14f941 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 4 Aug 2026 08:52:08 +0200 Subject: [PATCH 01/12] ci: Add pydantic-ai-slim to uv typing group --- pyproject.toml | 1 + uv.lock | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 356fa4227c..5ac71fb998 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ typing = [ "starlette>=1.3.1", "python-multipart>=0.0.32", "pydantic>=2.13.4", + "pydantic-ai-slim>=2.23.0", ] test = [ "dataclasses ; python_full_version < '3.7'", diff --git a/uv.lock b/uv.lock index fb1818bba8..b7c157b728 100644 --- a/uv.lock +++ b/uv.lock @@ -52,6 +52,7 @@ typing = [ { name = "openfeature-sdk" }, { name = "opentelemetry-distro", extras = ["otlp"] }, { name = "pydantic", specifier = ">=2.13.4" }, + { name = "pydantic-ai-slim", specifier = ">=2.23.0" }, { name = "pymongo" }, { name = "python-multipart", specifier = ">=0.0.32" }, { name = "starlette", specifier = ">=1.3.1" }, @@ -519,6 +520,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] +[[package]] +name = "genai-prices" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx2" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/9b/85e646305a90a2da18f1edf055498668391e71f9849d3e1754d66559a311/genai_prices-0.1.1.tar.gz", hash = "sha256:54a2237691e0aaefb057d10a0c3c20160accc9fc09521c64c03fcdb7a4a69f68", size = 91182, upload-time = "2026-08-01T09:02:49.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/5e/cfe36dff790ffad6aeff8a069b6f36743987ac17053579035ee0a67635dd/genai_prices-0.1.1-py3-none-any.whl", hash = "sha256:de2e3d8ea3ca1d0d292025995c598da447a74e94f22cd3342df46941aeb5416b", size = 95300, upload-time = "2026-08-01T09:02:48.308Z" }, +] + [[package]] name = "gevent" version = "26.5.0" @@ -619,6 +633,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, ] +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "grpcio" version = "1.80.0" @@ -942,6 +965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "logfire-api" +version = "4.39.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/f4/41f8647f6091fb9b9aac5a4b6d164bddb11d55b8369bf38de154ef91b8f4/logfire_api-4.39.0.tar.gz", hash = "sha256:1e885f95c37d58cdb927bbc6baea4f4a7c13066f6b3019758627d4dc442643d0", size = 90619, upload-time = "2026-07-24T18:31:36.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/d4/87747d12eaf2d852676fd6535df76df945ef62681f1eb5391b63d1fc05e2/logfire_api-4.39.0-py3-none-any.whl", hash = "sha256:20057bbd2898dec2eed02e2559bd73f4e10bc4b108987821df55e9c762da3ba8", size = 140413, upload-time = "2026-07-24T18:31:32.818Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -1394,6 +1426,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] +[[package]] +name = "pydantic-ai-slim" +version = "2.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/9f/53b19efefa041c1080f7c4ad41679a9293cce64f1265168a98cbe06a0ab7/pydantic_ai_slim-2.23.0.tar.gz", hash = "sha256:d16dcbfb2bfea0ee162bf0f499442fab5a4d69b41e4c54f3b694c2e90b983768", size = 965485, upload-time = "2026-08-04T01:58:20.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6f/539a255524178a8421d582271a8d7f8667b036f02b4ddc4f20abcc63888b/pydantic_ai_slim-2.23.0-py3-none-any.whl", hash = "sha256:a2fa3e56408bbf1b83900e3dd4ad9b137297742f450863c2f0f9a03a547d0e33", size = 1157486, upload-time = "2026-08-04T01:58:12.356Z" }, +] + [[package]] name = "pydantic-core" version = "2.46.4" @@ -1435,6 +1486,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] +[[package]] +name = "pydantic-graph" +version = "2.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/fc/273bac7d14fb62c060e0c20c51a9cc60e9e90a96d992fd20e77abf4b6ac1/pydantic_graph-2.23.0.tar.gz", hash = "sha256:54c9939f47fd8a268c96320d7d90e7cef037cbfd2625a675dc4028c1377f70ab", size = 45179, upload-time = "2026-08-04T01:58:23.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/c4/875cf853d205dc55422bd44ff0fbfac82e6e34ff7693df016fc3a4088d32/pydantic_graph-2.23.0-py3-none-any.whl", hash = "sha256:b0f12b4f72adb2a5522b5962c95e1a7b140cb3f631a628036f935e291a9e50ba", size = 52662, upload-time = "2026-08-04T01:58:15.858Z" }, +] + [[package]] name = "pydantic-settings" version = "2.14.2" From 8b5d99a6983de67aaad7e882ead2dce8dd77cb7f Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 4 Aug 2026 09:09:33 +0200 Subject: [PATCH 02/12] remove some type: ignore --- sentry_sdk/integrations/pydantic_ai/__init__.py | 12 ++++++------ .../integrations/pydantic_ai/spans/ai_client.py | 2 +- .../integrations/pydantic_ai/spans/invoke_agent.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 81e7cf8090..e79d38cc01 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -4,7 +4,7 @@ from sentry_sdk.utils import capture_internal_exceptions, parse_version try: - import pydantic_ai # type: ignore # noqa: F401 + import pydantic_ai # noqa: F401 from pydantic_ai import Agent except ImportError: raise DidNotEnable("pydantic-ai not installed") @@ -24,8 +24,8 @@ from typing import Any from pydantic_ai import ModelRequestContext, RunContext - from pydantic_ai.capabilities import Hooks # type: ignore - from pydantic_ai.messages import ModelResponse # type: ignore + from pydantic_ai.capabilities import Hooks + from pydantic_ai.messages import ModelResponse def register_hooks(hooks: "Hooks") -> None: @@ -33,7 +33,7 @@ def register_hooks(hooks: "Hooks") -> None: Creates hooks for chat model calls and register the hooks by adding the hooks to the `capabilities` argument passed to `Agent.__init__()`. """ - @hooks.on.before_model_request # type: ignore + @hooks.on.before_model_request async def on_request( ctx: "RunContext[None]", request_context: "ModelRequestContext" ) -> "ModelRequestContext": @@ -53,7 +53,7 @@ async def on_request( return request_context - @hooks.on.after_model_request # type: ignore + @hooks.on.after_model_request async def on_response( ctx: "RunContext[None]", *, @@ -73,7 +73,7 @@ async def on_response( return response - @hooks.on.model_request_error # type: ignore + @hooks.on.model_request_error async def on_error( ctx: "RunContext[None]", *, diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 27deb0c55c..0163284316 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: from typing import Any, Dict, List, Union - from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore + from pydantic_ai.messages import ModelMessage, SystemPromptPart from sentry_sdk._types import TextPart as SentryTextPart diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py index f0c68e85ba..5bd4457288 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -30,7 +30,7 @@ from typing import Any, Union try: - from pydantic_ai.messages import BinaryContent, ImageUrl # type: ignore + from pydantic_ai.messages import BinaryContent, ImageUrl except ImportError: BinaryContent = None ImageUrl = None From feeedf0782b51025dbad27d4ce4a8b4dc6707294 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 4 Aug 2026 09:17:10 +0200 Subject: [PATCH 03/12] more type fixes --- .../pydantic_ai/patches/graph_nodes.py | 2 +- .../integrations/pydantic_ai/patches/tools.py | 10 +++++----- .../integrations/pydantic_ai/spans/ai_client.py | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py index afb10395f4..bcd6ff967a 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py @@ -9,7 +9,7 @@ ) try: - from pydantic_ai._agent_graph import ModelRequestNode # type: ignore + from pydantic_ai._agent_graph import ModelRequestNode except ImportError: raise DidNotEnable("pydantic-ai not installed") diff --git a/sentry_sdk/integrations/pydantic_ai/patches/tools.py b/sentry_sdk/integrations/pydantic_ai/patches/tools.py index 5646b5d47c..958d729fbb 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/tools.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/tools.py @@ -14,11 +14,11 @@ try: try: - from pydantic_ai.tool_manager import ToolManager # type: ignore + from pydantic_ai.tool_manager import ToolManager except ImportError: from pydantic_ai._tool_manager import ToolManager # type: ignore - from pydantic_ai.exceptions import ToolRetryError # type: ignore + from pydantic_ai.exceptions import ToolRetryError except ImportError: raise DidNotEnable("pydantic-ai not installed") @@ -95,7 +95,7 @@ async def wrapped_execute_tool_call( return await original_execute_tool_call(self, validated, *args, **kwargs) - ToolManager.execute_tool_call = wrapped_execute_tool_call + ToolManager.execute_tool_call = wrapped_execute_tool_call # type: ignore[method-assign] def _patch_call_tool() -> None: @@ -110,7 +110,7 @@ def _patch_call_tool() -> None: - Dealing with signature mismatches from instrumented MCP servers - Complex nested toolset handling """ - original_call_tool = ToolManager._call_tool + original_call_tool = ToolManager._call_tool # type: ignore[attr-defined] @wraps(original_call_tool) async def wrapped_call_tool( @@ -174,4 +174,4 @@ async def wrapped_call_tool( **kwargs, ) - ToolManager._call_tool = wrapped_call_tool + ToolManager._call_tool = wrapped_call_tool # type: ignore[attr-defined] diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 0163284316..a2aeb26e1e 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -51,14 +51,14 @@ ) except ImportError: # Fallback if these classes are not available - BaseToolCallPart = None - BaseToolReturnPart = None - SystemPromptPart = None - UserPromptPart = None - TextPart = None - ThinkingPart = None - BinaryContent = None - ImageUrl = None + BaseToolCallPart = None # type: ignore[misc,assignment] + BaseToolReturnPart = None # type: ignore[misc,assignment] + SystemPromptPart = None # type: ignore[misc,assignment] + UserPromptPart = None # type: ignore[misc,assignment] + TextPart = None # type: ignore[misc,assignment] + ThinkingPart = None # type: ignore[misc,assignment] + BinaryContent = None # type: ignore[misc,assignment] + ImageUrl = None # type: ignore[misc,assignment] def _transform_system_instructions( From c651c74c140a55ad6f20a8d8a0f4081114bf18b6 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 4 Aug 2026 09:25:59 +0200 Subject: [PATCH 04/12] fix remaining failures --- .../integrations/pydantic_ai/__init__.py | 2 +- .../pydantic_ai/patches/agent_run.py | 6 +-- .../pydantic_ai/patches/graph_nodes.py | 4 +- .../pydantic_ai/spans/ai_client.py | 45 ++++++++++++++----- .../pydantic_ai/spans/invoke_agent.py | 8 ++-- .../integrations/pydantic_ai/spans/utils.py | 2 +- 6 files changed, 44 insertions(+), 23 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index e79d38cc01..db21861e71 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -108,7 +108,7 @@ def patched_init(self: "Agent[Any, Any]", *args: "Any", **kwargs: "Any") -> None return original_init(self, *args, **kwargs) - Agent.__init__ = patched_init + Agent.__init__ = patched_init # type: ignore[method-assign] class PydanticAIIntegration(Integration): diff --git a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py index 3039e40698..864c83a506 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py @@ -10,7 +10,7 @@ from ..utils import _capture_exception, pop_agent, push_agent try: - from pydantic_ai.agent import Agent # type: ignore + from pydantic_ai.agent import Agent except ImportError: raise DidNotEnable("pydantic-ai not installed") @@ -192,7 +192,7 @@ def _patch_agent_run() -> None: original_run_stream = Agent.run_stream # Wrap and apply patches for non-streaming methods - Agent.run = _create_run_wrapper(original_run, is_streaming=False) + Agent.run = _create_run_wrapper(original_run, is_streaming=False) # type: ignore[method-assign] # Wrap and apply patches for streaming methods - Agent.run_stream = _create_streaming_wrapper(original_run_stream) + Agent.run_stream = _create_streaming_wrapper(original_run_stream) # type: ignore[method-assign] diff --git a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py index bcd6ff967a..c2a033537c 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py @@ -70,7 +70,7 @@ async def wrapped_model_request_run(self: "Any", ctx: "Any") -> "Any": update_ai_client_span(span, model_response) return result - ModelRequestNode.run = wrapped_model_request_run + ModelRequestNode.run = wrapped_model_request_run # type: ignore[method-assign] # Patch ModelRequestNode.stream for streaming requests original_model_request_stream = ModelRequestNode.stream @@ -103,4 +103,4 @@ async def wrapped_model_request_stream(self: "Any", ctx: "Any") -> "Any": return wrapped_model_request_stream - ModelRequestNode.stream = create_wrapped_stream(original_model_request_stream) + ModelRequestNode.stream = create_wrapped_stream(original_model_request_stream) # type: ignore[method-assign] diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index a2aeb26e1e..263e280e11 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -93,7 +93,7 @@ def _get_system_instructions( for msg in messages: if hasattr(msg, "parts"): for part in msg.parts: - if SystemPromptPart and isinstance(part, SystemPromptPart): + if SystemPromptPart is not None and isinstance(part, SystemPromptPart): permanent_instructions.append(part) if hasattr(msg, "instructions") and msg.instructions is not None: @@ -141,15 +141,22 @@ def _set_input_messages( for part in msg.parts: role = "user" # Use isinstance checks with proper base classes - if SystemPromptPart and isinstance(part, SystemPromptPart): + if SystemPromptPart is not None and isinstance( + part, SystemPromptPart + ): continue elif ( - (TextPart and isinstance(part, TextPart)) - or (ThinkingPart and isinstance(part, ThinkingPart)) - or (BaseToolCallPart and isinstance(part, BaseToolCallPart)) + (TextPart is not None and isinstance(part, TextPart)) + or (ThinkingPart is not None and isinstance(part, ThinkingPart)) + or ( + BaseToolCallPart is not None + and isinstance(part, BaseToolCallPart) + ) ): role = "assistant" - elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): + elif BaseToolReturnPart is not None and isinstance( + part, BaseToolReturnPart + ): role = "tool" content: "List[Dict[str, Any] | str]" = [] @@ -157,7 +164,9 @@ def _set_input_messages( tool_call_id = None # Handle ToolCallPart (assistant requesting tool use) - if BaseToolCallPart and isinstance(part, BaseToolCallPart): + if BaseToolCallPart is not None and isinstance( + part, BaseToolCallPart + ): tool_call_data = {} if hasattr(part, "tool_name"): tool_call_data["name"] = part.tool_name @@ -166,7 +175,9 @@ def _set_input_messages( if tool_call_data: tool_calls = [tool_call_data] # Handle ToolReturnPart (tool result) - elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): + elif BaseToolReturnPart is not None and isinstance( + part, BaseToolReturnPart + ): if hasattr(part, "tool_name"): tool_call_id = part.tool_name if hasattr(part, "content"): @@ -179,9 +190,13 @@ def _set_input_messages( for item in part.content: if isinstance(item, str): content.append({"type": "text", "text": item}) - elif ImageUrl and isinstance(item, ImageUrl): + elif ImageUrl is not None and isinstance( + item, ImageUrl + ): content.append(_serialize_image_url_item(item)) - elif BinaryContent and isinstance(item, BinaryContent): + elif BinaryContent is not None and isinstance( + item, BinaryContent + ): content.append(_serialize_binary_content_item(item)) else: content.append(safe_serialize(item)) @@ -237,9 +252,15 @@ def _set_output_data( tool_calls = [] for part in response.parts: - if TextPart and isinstance(part, TextPart) and hasattr(part, "content"): + if ( + TextPart is not None + and isinstance(part, TextPart) + and hasattr(part, "content") + ): texts.append(part.content) - elif BaseToolCallPart and isinstance(part, BaseToolCallPart): + elif BaseToolCallPart is not None and isinstance( + part, BaseToolCallPart + ): tool_call_data = { "type": "function", } diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py index 5bd4457288..876cb1ecea 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -32,8 +32,8 @@ try: from pydantic_ai.messages import BinaryContent, ImageUrl except ImportError: - BinaryContent = None - ImageUrl = None + BinaryContent = None # type: ignore[misc,assignment] + ImageUrl = None # type: ignore[misc,assignment] def invoke_agent_span( @@ -123,9 +123,9 @@ def invoke_agent_span( for item in user_prompt: if isinstance(item, str): content.append({"text": item, "type": "text"}) - elif ImageUrl and isinstance(item, ImageUrl): + elif ImageUrl is not None and isinstance(item, ImageUrl): content.append(_serialize_image_url_item(item)) - elif BinaryContent and isinstance(item, BinaryContent): + elif BinaryContent is not None and isinstance(item, BinaryContent): content.append(_serialize_binary_content_item(item)) if content: messages.append( diff --git a/sentry_sdk/integrations/pydantic_ai/spans/utils.py b/sentry_sdk/integrations/pydantic_ai/spans/utils.py index 330496c6b2..cbce0c0c77 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/utils.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/utils.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from typing import Any, Dict, Union - from pydantic_ai.usage import RequestUsage, RunUsage # type: ignore + from pydantic_ai.usage import RequestUsage, RunUsage def _serialize_image_url_item(item: "Any") -> "Dict[str, Any]": From a43e80282fb5e2322d728249402d784c25ff7ab2 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 5 Aug 2026 09:11:01 +0200 Subject: [PATCH 05/12] test(pydantic-ai): Use FunctionModel for testing output data transformations --- .../pydantic_ai/test_pydantic_ai.py | 177 +++++++++--------- 1 file changed, 90 insertions(+), 87 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 51a1072690..8cea9e859e 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -7,7 +7,15 @@ from pydantic import Field from pydantic_ai import Agent from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior -from pydantic_ai.messages import BinaryContent, ImageUrl, UserPromptPart +from pydantic_ai.messages import ( + BinaryContent, + ImageUrl, + ModelResponse, + TextPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) from pydantic_ai.models.function import FunctionModel from pydantic_ai.usage import RequestUsage @@ -2827,39 +2835,6 @@ async def test_agent_without_name( assert "invoke_agent" in transaction["transaction"] -@pytest.mark.asyncio -async def test_model_response_without_parts(sentry_init, capture_items): - """ - Test handling of model response without parts attribute. - """ - from unittest.mock import MagicMock - - import sentry_sdk - from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_output_data - - sentry_init( - integrations=[PydanticAIIntegration()], - traces_sample_rate=1.0, - send_default_pii=True, - ) - - with sentry_sdk.start_transaction(op="test", name="test") as transaction: - span = sentry_sdk.start_span(op="test_span") - - # Create mock response without parts - mock_response = MagicMock() - mock_response.model_name = "test-model" - del mock_response.parts # Remove parts attribute - - # Should not raise, just skip formatting - _set_output_data(span, mock_response) - - span.finish() - - # Should not crash - assert transaction is not None - - @pytest.mark.asyncio async def test_input_messages_error_handling(sentry_init, capture_items): """ @@ -3073,44 +3048,102 @@ async def test_message_parts_with_list_content(sentry_init, capture_items): assert transaction is not None +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.asyncio -async def test_output_data_with_text_and_tool_calls(sentry_init, capture_items): +async def test_output_data_transformations( + sentry_init, capture_items, capture_events, span_streaming +): """ - Test that _set_output_data handles both text and tool calls in response. + Test transformation of outputs from `Model.get_response()` and `Model.stream_response()`. """ - from unittest.mock import MagicMock - - import sentry_sdk - from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_output_data - sentry_init( integrations=[PydanticAIIntegration()], traces_sample_rate=1.0, send_default_pii=True, + stream_gen_ai_spans=False, + trace_lifecycle="stream" if span_streaming else "static", ) - with sentry_sdk.start_transaction(op="test", name="test") as transaction: - span = sentry_sdk.start_span(op="test_span") + def response_model(messages, info): + if isinstance(messages[-1].parts[-1], ToolReturnPart): + return ModelResponse(parts=[TextPart(content="The answer is 15.")]) - # Create mock response with both TextPart and ToolCallPart - from pydantic_ai import messages + return ModelResponse( + parts=[ToolCallPart(tool_name="multiply", args={"a": 5, "b": 3})] + ) - text_part = messages.TextPart(content="Here's the result") - tool_call_part = MagicMock() - tool_call_part.tool_name = "test_tool" - tool_call_part.args = {"x": 5} + agent = Agent(FunctionModel(response_model), name="test_agent") - mock_response = MagicMock() - mock_response.model_name = "test-model" - mock_response.parts = [text_part, tool_call_part] + @agent.tool_plain + def multiply(a: int, b: int) -> int: + """Multiply two numbers.""" + return a * b - # Should handle both text and tool calls - _set_output_data(span, mock_response) + if span_streaming: + items = capture_items("span") - span.finish() + await agent.run( + "What is 5 times 3?", + ) + sentry_sdk.flush() - # Should not crash - assert transaction is not None + spans = [item.payload for item in items] + + invoke_agent_span = next( + span + for span in spans + if span["attributes"].get("sentry.op") == "gen_ai.invoke_agent" + ) + assert invoke_agent_span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == ( + "The answer is 15." + ) + + chat_spans = [ + span + for span in spans + if span["attributes"].get("sentry.op") == "gen_ai.chat" + ] + assert json.loads( + chat_spans[0]["attributes"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS] + ) == [ + { + "type": "function", + "name": "multiply", + "arguments": '{"a": 5, "b": 3}', + } + ] + assert ( + chat_spans[1]["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] + == "The answer is 15." + ) + else: + events = capture_events() + + await agent.run( + "What is 5 times 3?", + ) + + (transaction,) = events + assert transaction["contexts"]["trace"]["op"] == "gen_ai.invoke_agent" + assert transaction["contexts"]["trace"]["data"][ + SPANDATA.GEN_AI_RESPONSE_TEXT + ] == ("The answer is 15.") + + chat_spans = [ + span for span in transaction["spans"] if span["op"] == "gen_ai.chat" + ] + assert json.loads( + chat_spans[0]["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS] + ) == [ + { + "type": "function", + "name": "multiply", + "arguments": '{"a": 5, "b": 3}', + } + ] + assert ( + chat_spans[1]["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "The answer is 15." + ) @pytest.mark.asyncio @@ -3246,36 +3279,6 @@ async def test_set_input_messages_without_prompts(sentry_init, capture_items): assert transaction is not None -@pytest.mark.asyncio -async def test_set_output_data_without_prompts(sentry_init, capture_items): - """ - Test that _set_output_data respects _should_send_prompts(). - """ - from unittest.mock import MagicMock - - import sentry_sdk - from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_output_data - - sentry_init( - integrations=[PydanticAIIntegration(include_prompts=False)], - traces_sample_rate=1.0, - send_default_pii=True, - ) - - with sentry_sdk.start_transaction(op="test", name="test") as transaction: - span = sentry_sdk.start_span(op="test_span") - - # Even with response, should not set output data - mock_response = MagicMock() - mock_response.model_name = "test" - _set_output_data(span, mock_response) - - span.finish() - - # Should not crash and should not set output - assert transaction is not None - - @pytest.mark.asyncio async def test_get_model_name_with_exception_in_callable(sentry_init, capture_items): """ From 53126dcbfb958bdf1265d23b53aad7052c247fdd Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 5 Aug 2026 09:46:39 +0200 Subject: [PATCH 06/12] feat(pydantic-ai): Record gen_ai.output.messages including ThinkingPart --- sentry_sdk/_types.py | 9 ++++ .../pydantic_ai/spans/ai_client.py | 52 +++++++++++-------- .../pydantic_ai/test_pydantic_ai.py | 47 ++++++++--------- 3 files changed, 60 insertions(+), 48 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index d3c70ddfa4..b6c6f320ea 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -472,6 +472,15 @@ class TextPart(TypedDict): type: Literal["text"] content: str + class ReasoningPart(TypedDict): + type: Literal["reasoning"] + content: str + + class ToolCallPart(TypedDict): + type: Literal["tool_call"] + name: str + arguments: Any + class ToolDefinition(TypedDict): type: str name: NotRequired[str] diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 263e280e11..0b31885408 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -34,9 +34,9 @@ if TYPE_CHECKING: from typing import Any, Dict, List, Union - from pydantic_ai.messages import ModelMessage, SystemPromptPart + from pydantic_ai.messages import ModelMessage, ModelResponse, SystemPromptPart - from sentry_sdk._types import TextPart as SentryTextPart + from sentry_sdk import _types try: from pydantic_ai.messages import ( @@ -59,13 +59,14 @@ ThinkingPart = None # type: ignore[misc,assignment] BinaryContent = None # type: ignore[misc,assignment] ImageUrl = None # type: ignore[misc,assignment] + ThinkingPart = None # type: ignore[misc,assignment] def _transform_system_instructions( permanent_instructions: "list[SystemPromptPart]", current_instructions: "list[str]", -) -> "list[SentryTextPart]": - text_parts: "list[SentryTextPart]" = [ +) -> "list[_types.TextPart]": + text_parts: "list[_types.TextPart]" = [ { "type": "text", "content": instruction.content, @@ -231,7 +232,7 @@ def _set_input_messages( def _set_output_data( - span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", response: "Any" + span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", response: "ModelResponse" ) -> None: """Set output data on a span.""" if not _should_send_prompts(): @@ -246,10 +247,8 @@ def _set_output_data( set_on_span(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_name) try: - # Extract text from ModelResponse if hasattr(response, "parts"): - texts = [] - tool_calls = [] + parts: "list[Union[_types.TextPart, _types.ReasoningPart, _types.ToolCallPart]]" = [] for part in response.parts: if ( @@ -257,26 +256,32 @@ def _set_output_data( and isinstance(part, TextPart) and hasattr(part, "content") ): - texts.append(part.content) + parts.append( + {"type": "text", "content": safe_serialize(part.content)} + ) + + elif ThinkingPart is not None and isinstance(part, ThinkingPart): + parts.append( + { + "type": "reasoning", + "content": safe_serialize(part.content), + } + ) + elif BaseToolCallPart is not None and isinstance( part, BaseToolCallPart ): - tool_call_data = { - "type": "function", - } + tool_part: "_types.ToolCallPart" = {"type": "tool_call"} if hasattr(part, "tool_name"): - tool_call_data["name"] = part.tool_name + tool_part["name"] = part.tool_name if hasattr(part, "args"): - tool_call_data["arguments"] = safe_serialize(part.args) - tool_calls.append(tool_call_data) - - if texts: - set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, texts) + tool_part["arguments"] = safe_serialize(part.args) + parts.append(tool_part) - if tool_calls: - set_on_span( - SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls) - ) + set_on_span( + SPANDATA.GEN_AI_OUTPUT_MESSAGES, + json.dumps({"role": "assistant", "parts": parts}), + ) except Exception: # If we fail to format output, just skip it @@ -338,7 +343,8 @@ def ai_client_span( def update_ai_client_span( - span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", model_response: "Any" + span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", + model_response: "ModelResponse", ) -> None: """Update the AI client span with response data.""" if not span: diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 8cea9e859e..e635f0fc8b 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -12,6 +12,7 @@ ImageUrl, ModelResponse, TextPart, + ThinkingPart, ToolCallPart, ToolReturnPart, UserPromptPart, @@ -3066,7 +3067,12 @@ async def test_output_data_transformations( def response_model(messages, info): if isinstance(messages[-1].parts[-1], ToolReturnPart): - return ModelResponse(parts=[TextPart(content="The answer is 15.")]) + return ModelResponse( + parts=[ + ThinkingPart(content="5 times 3 is 15."), + TextPart(content="The answer is 15."), + ] + ) return ModelResponse( parts=[ToolCallPart(tool_name="multiply", args={"a": 5, "b": 3})] @@ -3104,18 +3110,14 @@ def multiply(a: int, b: int) -> int: if span["attributes"].get("sentry.op") == "gen_ai.chat" ] assert json.loads( - chat_spans[0]["attributes"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS] - ) == [ - { - "type": "function", - "name": "multiply", - "arguments": '{"a": 5, "b": 3}', - } - ] - assert ( - chat_spans[1]["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] - == "The answer is 15." - ) + chat_spans[1]["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES] + ) == { + "role": "assistant", + "parts": [ + {"type": "reasoning", "content": "5 times 3 is 15."}, + {"type": "text", "content": "The answer is 15."}, + ], + } else: events = capture_events() @@ -3132,18 +3134,13 @@ def multiply(a: int, b: int) -> int: chat_spans = [ span for span in transaction["spans"] if span["op"] == "gen_ai.chat" ] - assert json.loads( - chat_spans[0]["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS] - ) == [ - { - "type": "function", - "name": "multiply", - "arguments": '{"a": 5, "b": 3}', - } - ] - assert ( - chat_spans[1]["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "The answer is 15." - ) + assert json.loads(chat_spans[1]["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == { + "role": "assistant", + "parts": [ + {"type": "reasoning", "content": "5 times 3 is 15."}, + {"type": "text", "content": "The answer is 15."}, + ], + } @pytest.mark.asyncio From 170cb064e9a13e96ce7629d49ffab20f83f10edf Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 5 Aug 2026 09:53:24 +0200 Subject: [PATCH 07/12] make mypy happy --- .../integrations/pydantic_ai/patches/graph_nodes.py | 8 +++++--- sentry_sdk/integrations/pydantic_ai/spans/ai_client.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py index c2a033537c..3a7d2fa635 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py @@ -16,7 +16,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Callable + from typing import Any, Callable, Optional + + from pydantic_ai.messages import ModelResponse def _extract_span_data(node: "Any", ctx: "Any") -> "tuple[list[Any], Any, Any]": @@ -63,7 +65,7 @@ async def wrapped_model_request_run(self: "Any", ctx: "Any") -> "Any": result = await original_model_request_run(self, ctx) # Extract response from result if available - model_response = None + model_response: "Optional[ModelResponse]" = None if hasattr(result, "model_response"): model_response = result.model_response @@ -93,7 +95,7 @@ async def wrapped_model_request_stream(self: "Any", ctx: "Any") -> "Any": # After streaming completes, update span with response data # The ModelRequestNode stores the final response in _result - model_response = None + model_response: "Optional[ModelResponse]" = None if hasattr(self, "_result") and self._result is not None: # _result is a NextNode containing the model_response if hasattr(self._result, "model_response"): diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 0b31885408..77c7882a81 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -32,7 +32,7 @@ ) if TYPE_CHECKING: - from typing import Any, Dict, List, Union + from typing import Any, Dict, List, Optional, Union from pydantic_ai.messages import ModelMessage, ModelResponse, SystemPromptPart @@ -244,7 +244,7 @@ def _set_output_data( set_on_span = ( span.set_attribute if isinstance(span, StreamedSpan) else span.set_data ) - set_on_span(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_name) + set_on_span(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_name) # type: ignore[arg-type] try: if hasattr(response, "parts"): @@ -344,7 +344,7 @@ def ai_client_span( def update_ai_client_span( span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", - model_response: "ModelResponse", + model_response: "Optional[ModelResponse]", ) -> None: """Update the AI client span with response data.""" if not span: From ace05fa8d5e7facd49002528f380beccfc5366ec Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 5 Aug 2026 09:55:12 +0200 Subject: [PATCH 08/12] make mypy even happier --- sentry_sdk/_types.py | 4 ++-- sentry_sdk/integrations/pydantic_ai/spans/ai_client.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index b6c6f320ea..59e501d7da 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -478,8 +478,8 @@ class ReasoningPart(TypedDict): class ToolCallPart(TypedDict): type: Literal["tool_call"] - name: str - arguments: Any + name: NotRequired[str] + arguments: NotRequired[Any] class ToolDefinition(TypedDict): type: str diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 77c7882a81..0196327b94 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -232,7 +232,8 @@ def _set_input_messages( def _set_output_data( - span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", response: "ModelResponse" + span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", + response: "Optional[ModelResponse]", ) -> None: """Set output data on a span.""" if not _should_send_prompts(): From 63cad6f2fbbf4d27e1af76f99ccf9dbabfc9ecff Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 5 Aug 2026 10:10:54 +0200 Subject: [PATCH 09/12] test cleanup --- .../pydantic_ai/test_pydantic_ai.py | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 8cea9e859e..6bf6248192 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -3049,18 +3049,19 @@ async def test_message_parts_with_list_content(sentry_init, capture_items): @pytest.mark.parametrize("span_streaming", [True, False]) +@pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) @pytest.mark.asyncio async def test_output_data_transformations( - sentry_init, capture_items, capture_events, span_streaming + sentry_init, capture_items, capture_events, span_streaming, stream_gen_ai_spans ): """ - Test transformation of outputs from `Model.get_response()` and `Model.stream_response()`. + Test transformation of the model response from `Hooks.on.after_model_request`. """ sentry_init( integrations=[PydanticAIIntegration()], traces_sample_rate=1.0, send_default_pii=True, - stream_gen_ai_spans=False, + stream_gen_ai_spans=stream_gen_ai_spans, trace_lifecycle="stream" if span_streaming else "static", ) @@ -3082,9 +3083,7 @@ def multiply(a: int, b: int) -> int: if span_streaming: items = capture_items("span") - await agent.run( - "What is 5 times 3?", - ) + await agent.run("What is 5 times 3?") sentry_sdk.flush() spans = [item.payload for item in items] @@ -3098,6 +3097,36 @@ def multiply(a: int, b: int) -> int: "The answer is 15." ) + chat_spans = [ + span + for span in spans + if span["attributes"].get("sentry.op") == "gen_ai.chat" + ] + assert json.loads( + chat_spans[0]["attributes"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS] + ) == [ + { + "type": "function", + "name": "multiply", + "arguments": '{"a": 5, "b": 3}', + } + ] + assert ( + chat_spans[1]["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] + == "The answer is 15." + ) + elif stream_gen_ai_spans: + items = capture_items("transaction", "span") + + await agent.run("What is 5 times 3?") + + (transaction,) = (item.payload for item in items if item.type == "transaction") + assert transaction["contexts"]["trace"]["op"] == "gen_ai.invoke_agent" + assert transaction["contexts"]["trace"]["data"][ + SPANDATA.GEN_AI_RESPONSE_TEXT + ] == ("The answer is 15.") + + spans = [item.payload for item in items if item.type == "span"] chat_spans = [ span for span in spans @@ -3119,9 +3148,7 @@ def multiply(a: int, b: int) -> int: else: events = capture_events() - await agent.run( - "What is 5 times 3?", - ) + await agent.run("What is 5 times 3?") (transaction,) = events assert transaction["contexts"]["trace"]["op"] == "gen_ai.invoke_agent" From a6014748b8c154b374d9f8269c53af1f29d374e0 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 5 Aug 2026 10:27:11 +0200 Subject: [PATCH 10/12] wrap in array --- .../pydantic_ai/spans/ai_client.py | 2 +- .../pydantic_ai/test_pydantic_ai.py | 114 ++++++++++-------- 2 files changed, 64 insertions(+), 52 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 0196327b94..9307875266 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -281,7 +281,7 @@ def _set_output_data( set_on_span( SPANDATA.GEN_AI_OUTPUT_MESSAGES, - json.dumps({"role": "assistant", "parts": parts}), + json.dumps([{"role": "assistant", "parts": parts}]), ) except Exception: diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 4d66f88e4c..075c0d2cc7 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -3110,25 +3110,29 @@ def multiply(a: int, b: int) -> int: ] assert json.loads( chat_spans[0]["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES] - ) == { - "role": "assistant", - "parts": [ - { - "type": "tool_call", - "name": "multiply", - "arguments": '{"a": 5, "b": 3}', - } - ], - } + ) == [ + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "name": "multiply", + "arguments": '{"a": 5, "b": 3}', + } + ], + } + ] assert json.loads( chat_spans[1]["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES] - ) == { - "role": "assistant", - "parts": [ - {"type": "reasoning", "content": "5 times 3 is 15."}, - {"type": "text", "content": "The answer is 15."}, - ], - } + ) == [ + { + "role": "assistant", + "parts": [ + {"type": "reasoning", "content": "5 times 3 is 15."}, + {"type": "text", "content": "The answer is 15."}, + ], + } + ] elif stream_gen_ai_spans: items = capture_items("transaction", "span") @@ -3148,25 +3152,29 @@ def multiply(a: int, b: int) -> int: ] assert json.loads( chat_spans[0]["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES] - ) == { - "role": "assistant", - "parts": [ - { - "type": "tool_call", - "name": "multiply", - "arguments": '{"a": 5, "b": 3}', - } - ], - } + ) == [ + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "name": "multiply", + "arguments": '{"a": 5, "b": 3}', + } + ], + } + ] assert json.loads( chat_spans[1]["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES] - ) == { - "role": "assistant", - "parts": [ - {"type": "reasoning", "content": "5 times 3 is 15."}, - {"type": "text", "content": "The answer is 15."}, - ], - } + ) == [ + { + "role": "assistant", + "parts": [ + {"type": "reasoning", "content": "5 times 3 is 15."}, + {"type": "text", "content": "The answer is 15."}, + ], + } + ] else: events = capture_events() @@ -3181,23 +3189,27 @@ def multiply(a: int, b: int) -> int: chat_spans = [ span for span in transaction["spans"] if span["op"] == "gen_ai.chat" ] - assert json.loads(chat_spans[0]["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == { - "role": "assistant", - "parts": [ - { - "type": "tool_call", - "name": "multiply", - "arguments": '{"a": 5, "b": 3}', - } - ], - } - assert json.loads(chat_spans[1]["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == { - "role": "assistant", - "parts": [ - {"type": "reasoning", "content": "5 times 3 is 15."}, - {"type": "text", "content": "The answer is 15."}, - ], - } + assert json.loads(chat_spans[0]["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "name": "multiply", + "arguments": '{"a": 5, "b": 3}', + } + ], + } + ] + assert json.loads(chat_spans[1]["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "reasoning", "content": "5 times 3 is 15."}, + {"type": "text", "content": "The answer is 15."}, + ], + } + ] @pytest.mark.asyncio From f41088f570d503852043342c95eeceed64a43e63 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 5 Aug 2026 10:30:58 +0200 Subject: [PATCH 11/12] remove unneccessary safe_serialize --- sentry_sdk/integrations/pydantic_ai/spans/ai_client.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 9307875266..ae672a8556 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -257,15 +257,13 @@ def _set_output_data( and isinstance(part, TextPart) and hasattr(part, "content") ): - parts.append( - {"type": "text", "content": safe_serialize(part.content)} - ) + parts.append({"type": "text", "content": part.content}) elif ThinkingPart is not None and isinstance(part, ThinkingPart): parts.append( { "type": "reasoning", - "content": safe_serialize(part.content), + "content": part.content, } ) From 1741feb5063997db7ae60a4cb72d5b7b5081b0c5 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 5 Aug 2026 14:43:26 +0200 Subject: [PATCH 12/12] only set attribute if parts is nonempty --- sentry_sdk/integrations/pydantic_ai/spans/ai_client.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index ae672a8556..c89b8609d8 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -277,10 +277,11 @@ def _set_output_data( tool_part["arguments"] = safe_serialize(part.args) parts.append(tool_part) - set_on_span( - SPANDATA.GEN_AI_OUTPUT_MESSAGES, - json.dumps([{"role": "assistant", "parts": parts}]), - ) + if parts: + set_on_span( + SPANDATA.GEN_AI_OUTPUT_MESSAGES, + json.dumps([{"role": "assistant", "parts": parts}]), + ) except Exception: # If we fail to format output, just skip it