From e85b332b82ea4dc8bee5ea4c966af0cfb0346435 Mon Sep 17 00:00:00 2001 From: Frederic Barthelemy Date: Thu, 1 Oct 2026 23:48:21 -0700 Subject: [PATCH 1/2] fix(openai-agents): Serialize response output items with `model_dump()` Recording tool calls and non-text message content called the deprecated Pydantic v2 `dict()` method, which emits `PydanticDeprecatedSince20`. `openai-agents` requires Pydantic v2 in every supported version, so `model_dump()` is always available. --- .../integrations/openai_agents/utils.py | 4 +- .../openai_agents/test_openai_agents.py | 97 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/openai_agents/utils.py b/sentry_sdk/integrations/openai_agents/utils.py index 01303e0e58..0e160193ed 100644 --- a/sentry_sdk/integrations/openai_agents/utils.py +++ b/sentry_sdk/integrations/openai_agents/utils.py @@ -226,14 +226,14 @@ def _set_output_data( for output in result.output: if output.type == "function_call": - output_messages["tool"].append(output.dict()) + output_messages["tool"].append(output.model_dump()) elif output.type == "message": for output_message in output.content: try: output_messages["response"].append(output_message.text) except AttributeError: # Unknown output message type, just return the json - output_messages["response"].append(output_message.dict()) + output_messages["response"].append(output_message.model_dump()) if record_outputs and len(output_messages["tool"]) > 0: if isinstance(span, StreamedSpan): diff --git a/tests/integrations/openai_agents/test_openai_agents.py b/tests/integrations/openai_agents/test_openai_agents.py index 8bc49a2c9f..3c58ad9161 100644 --- a/tests/integrations/openai_agents/test_openai_agents.py +++ b/tests/integrations/openai_agents/test_openai_agents.py @@ -2,6 +2,7 @@ import json import logging import os +import warnings from unittest import mock from unittest.mock import MagicMock, patch @@ -27,6 +28,7 @@ from agents.version import __version__ as OPENAI_AGENTS_VERSION from openai import AsyncOpenAI, InternalServerError from openai.types.responses.tool_param import CodeInterpreter, ImageGeneration +from pydantic.warnings import PydanticDeprecatedSince20 from sentry_sdk.integrations import DidNotEnable @@ -82,6 +84,7 @@ Response, ResponseCompletedEvent, ResponseCreatedEvent, + ResponseOutputRefusal, ResponseTextDeltaEvent, ResponseUsage, ) @@ -3426,6 +3429,100 @@ async def test_tool_execution_span( assert ai_client_span2["data"]["gen_ai.usage.total_tokens"] == 25 +@pytest.mark.asyncio +async def test_response_output_serialization_emits_no_pydantic_deprecation_warning( + sentry_init, + capture_items, + test_agent, + simple_test_tool, + get_model_response, + nonstreaming_responses_tool_call_model_responses, +): + client = AsyncOpenAI(api_key="test-key") + model = OpenAIResponsesModel(model="gpt-4", openai_client=client) + agent_with_tool = test_agent.clone(tools=[simple_test_tool], model=model) + + usage = ResponseUsage( + input_tokens=10, + input_tokens_details=InputTokensDetails( + cached_tokens=0, + cache_write_tokens=0, + ), + output_tokens=5, + output_tokens_details=OutputTokensDetails( + reasoning_tokens=0, + ), + total_tokens=15, + ) + responses = nonstreaming_responses_tool_call_model_responses( + tool_name="simple_test_tool", + arguments='{"message": "hello"}', + response_model="gpt-4", + response_text="Task completed using the tool", + response_ids=iter(["resp_tool_123", "resp_final_123"]), + usages=iter([usage, usage]), + ) + tool_call_response = next(responses) + # `openai-agents` can raise `ModelRefusalError` when a refusal ends the run. + tool_call_response.output.append( + ResponseOutputMessage( + id="msg_refusal", + type="message", + status="completed", + content=[ + ResponseOutputRefusal( + refusal="Refused by the model", + type="refusal", + ) + ], + role="assistant", + ) + ) + final_response = next(responses) + + sentry_init( + integrations=[OpenAIAgentsIntegration()], + disabled_integrations=[StdlibIntegration], + traces_sample_rate=1.0, + send_default_pii=True, + ) + items = capture_items("span") + + with patch.object( + agent_with_tool.model._client._client, + "send", + side_effect=[ + get_model_response(tool_call_response, serialize_pydantic=True), + get_model_response(final_response, serialize_pydantic=True), + ], + ): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await agents.Runner.run( + agent_with_tool, + "Please use the simple test tool", + run_config=test_run_config, + ) + + assert not [ + warning + for warning in caught + if issubclass(warning.category, PydanticDeprecatedSince20) + ] + + tool_call_span = next( + item.payload + for item in items + if item.payload["attributes"]["sentry.op"] == OP.GEN_AI_CHAT + ) + assert ( + "simple_test_tool" in tool_call_span["attributes"]["gen_ai.response.tool_calls"] + ) + assert ( + "Refused by the model" in tool_call_span["attributes"]["gen_ai.response.text"] + ) + + @pytest.mark.parametrize("user_hooks", [True, False]) @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) From 1c14af8afeafbe951006d453aef663088588632d Mon Sep 17 00:00:00 2001 From: Frederic Barthelemy Date: Tue, 13 Oct 2026 16:43:32 -0700 Subject: [PATCH 2/2] fix(openai): Serialize Responses API output items with `model_dump()` Recording Responses API tool calls and non-text message content called the deprecated Pydantic v2 `dict()` method, which emits `PydanticDeprecatedSince20`. `openai`'s `BaseModel` defines `model_dump()` under Pydantic v1 in every supported version from `1.0.0`, so the call works with both Pydantic majors. --- sentry_sdk/integrations/openai.py | 10 ++-- tests/integrations/openai/test_openai.py | 74 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index 04ae33daff..16d08c6123 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -759,7 +759,7 @@ def _set_common_output_data( if record_outputs: for output in response.output: if output.type == "function_call": - output_messages["tool"].append(output.dict()) + output_messages["tool"].append(output.model_dump()) elif output.type == "message": for output_message in output.content: try: @@ -767,7 +767,7 @@ def _set_common_output_data( except AttributeError: # Unknown output message type, just return the json output_messages["response"].append( - output_message.dict() + output_message.model_dump() ) if len(output_messages["tool"]) > 0: @@ -790,14 +790,16 @@ def _set_common_output_data( ): for output in response.output: if output.type == "function_call": - output_messages["tool"].append(output.dict()) + output_messages["tool"].append(output.model_dump()) elif output.type == "message": for output_message in output.content: try: output_messages["response"].append(output_message.text) # type: ignore[union-attr] except AttributeError: # Unknown output message type, just return the json - output_messages["response"].append(output_message.dict()) + output_messages["response"].append( + output_message.model_dump() + ) if len(output_messages["tool"]) > 0: set_data_normalized( diff --git a/tests/integrations/openai/test_openai.py b/tests/integrations/openai/test_openai.py index e578b03fdc..200314fed1 100644 --- a/tests/integrations/openai/test_openai.py +++ b/tests/integrations/openai/test_openai.py @@ -1,4 +1,5 @@ import json +import warnings import pytest @@ -46,6 +47,11 @@ except ImportError: pass +try: + from pydantic.warnings import PydanticDeprecatedSince20 +except ImportError: + PydanticDeprecatedSince20 = None + SKIP_RESPONSES_TESTS = False try: @@ -6248,6 +6254,74 @@ def test_responses_api_data_collection_outputs_shapes( assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS not in span_data +@pytest.mark.parametrize( + "data_collection,send_default_pii", + [ + pytest.param({"gen_ai": {"outputs": True}}, False, id="data-collection"), + pytest.param(None, True, id="send-default-pii"), + ], +) +@pytest.mark.skipif(SKIP_RESPONSES_TESTS, reason="Responses API not available") +@pytest.mark.skipif( + PydanticDeprecatedSince20 is None, + reason="Pydantic v1 does not deprecate `dict()`", +) +def test_responses_api_output_serialization_emits_no_pydantic_deprecation_warning( + sentry_init, + capture_events, + capture_items, + data_collection, + send_default_pii, +): + init_kwargs = { + "integrations": [OpenAIIntegration()], + "disabled_integrations": [StdlibIntegration], + "traces_sample_rate": 1.0, + "send_default_pii": send_default_pii, + "stream_gen_ai_spans": False, + } + if data_collection is not None: + init_kwargs["data_collection"] = data_collection + + sentry_init(**init_kwargs) + + client = OpenAI(api_key="z") + client.responses._post = mock.Mock( + return_value=_make_responses_api_response( + output=[ + _make_responses_api_output_message( + content=[ + ResponseOutputRefusal( + refusal="I cannot help with that.", + type="refusal", + ), + ] + ), + _make_responses_api_function_call(), + ] + ) + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + span_data = _collect_responses_span_data( + capture_events, + capture_items, + False, + False, + lambda: client.responses.create(model="gpt-4o", input="hello"), + ) + + assert not [ + warning + for warning in caught + if issubclass(warning.category, PydanticDeprecatedSince20) + ] + + assert "I cannot help with that." in span_data[SPANDATA.GEN_AI_RESPONSE_TEXT] + assert "get_current_weather" in span_data[SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS] + + @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) @pytest.mark.parametrize(