diff --git a/sentry_sdk/ai/_openai_responses_api.py b/sentry_sdk/ai/_openai_responses_api.py index 8f751c3248..11c4962eaf 100644 --- a/sentry_sdk/ai/_openai_responses_api.py +++ b/sentry_sdk/ai/_openai_responses_api.py @@ -1,9 +1,15 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterable if TYPE_CHECKING: - from typing import Union + from typing import Iterable, Union - from openai.types.responses import ResponseInputItemParam, ResponseInputParam + from openai.types.responses import ( + ResponseInputItemParam, + ResponseInputParam, + ToolParam, + ) + + from sentry_sdk._types import ToolDefinition def _is_system_instruction(message: "ResponseInputItemParam") -> bool: @@ -20,3 +26,52 @@ def _get_system_instructions( return [] return [message for message in messages if _is_system_instruction(message)] + + +def _transform_tool_definitions(tools: "Iterable[ToolParam]") -> "list[ToolDefinition]": + """ + Transform tool definitions to the schema used by the "gen_ai.tool.definitions" attribute. + Includes special handling for tools where the type includes a name, description or parameters. + """ + if not isinstance(tools, Iterable): + return [] + + tool_definitions = [] + for tool in tools: + if not isinstance(tool, dict) or "type" not in tool: + continue + + if tool["type"] == "function": + tool_definition: "ToolDefinition" = { + "type": "function", + } + + if "name" in tool: + tool_definition["name"] = tool["name"] + + if "description" in tool and tool["description"] is not None: + tool_definition["description"] = tool["description"] + + if "parameters" in tool and tool["parameters"] is not None: + tool_definition["parameters"] = tool["parameters"] + + tool_definitions.append(tool_definition) + continue + + if tool["type"] == "custom": + tool_definition = { + "type": "custom", + } + + if "name" in tool: + tool_definition["name"] = tool["name"] + + if "description" in tool: + tool_definition["description"] = tool["description"] + + tool_definitions.append(tool_definition) + continue + + tool_definitions.append({"type": tool["type"]}) + + return tool_definitions diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index 1b25b6b962..a43aad0bfc 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -26,6 +26,9 @@ from sentry_sdk.ai._openai_responses_api import ( _is_system_instruction as _is_system_instruction_responses, ) +from sentry_sdk.ai._openai_responses_api import ( + _transform_tool_definitions as _transform_tool_definitions_responses, +) from sentry_sdk.ai.monitoring import record_token_usage from sentry_sdk.ai.utils import ( get_start_span_function, @@ -46,7 +49,6 @@ capture_internal_exceptions, event_from_exception, reraise, - safe_serialize, ) if TYPE_CHECKING: @@ -330,15 +332,17 @@ def _set_responses_api_input_data( explicit_instructions: "Union[Optional[str], Omit]" = kwargs.get("instructions") messages: "Optional[Union[str, ResponseInputParam]]" = kwargs.get("input") - tools = kwargs.get("tools") - if tools is not None and _is_given(tools) and len(tools) > 0: - set_data_normalized( - span, SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, safe_serialize(tools) - ) - set_on_span = ( span.set_attribute if isinstance(span, StreamedSpan) else span.set_data ) + + tools = kwargs.get("tools") + if tools is not None and _is_given(tools): + set_on_span( + SPANDATA.GEN_AI_TOOL_DEFINITIONS, + json.dumps(_transform_tool_definitions_responses(tools)), + ) + model = kwargs.get("model") if model is not None: set_on_span(SPANDATA.GEN_AI_REQUEST_MODEL, model) diff --git a/tests/integrations/openai/test_openai.py b/tests/integrations/openai/test_openai.py index 53f993b5c0..b66d98ffc2 100644 --- a/tests/integrations/openai/test_openai.py +++ b/tests/integrations/openai/test_openai.py @@ -37,10 +37,13 @@ try: from openai.types.responses import ( + CustomToolParam, + FunctionToolParam, Response, ResponseOutputMessage, ResponseOutputText, ResponseUsage, + WebSearchToolParam, ) from openai.types.responses.response_completed_event import ResponseCompletedEvent from openai.types.responses.response_created_event import ResponseCreatedEvent @@ -4185,6 +4188,197 @@ def test_ai_client_span_responses_api_no_pii( assert "gen_ai.response.text" not in spans[0]["data"] +@pytest.mark.parametrize("span_streaming", [True, False]) +@pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) +@pytest.mark.skipif(SKIP_RESPONSES_TESTS, reason="Responses API not available") +def test_ai_client_span_responses_tool_definitions( + sentry_init, + capture_events, + capture_items, + stream_gen_ai_spans, + span_streaming, +): + sentry_init( + integrations=[OpenAIIntegration()], + disabled_integrations=[StdlibIntegration], + traces_sample_rate=1.0, + stream_gen_ai_spans=stream_gen_ai_spans, + trace_lifecycle="stream" if span_streaming else "static", + ) + + client = OpenAI(api_key="z") + client.responses._post = mock.Mock(return_value=EXAMPLE_RESPONSE) + + if span_streaming: + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="openai tx"): + client.responses.create( + model="gpt-4o", + input="How do I check if a Python object is an instance of a class?", + tools=[ + FunctionToolParam( + type="function", + name="name", + description="description", + parameters={ + "type": "object", + "properties": { + "city": {"type": "string"}, + "state": {"type": "string"}, + }, + "required": ["city", "state"], + "additionalProperties": False, + }, + strict=True, + ), + CustomToolParam( + type="custom", name="name", description="description" + ), + WebSearchToolParam(type="web_search"), + ], + ) + + sentry_sdk.flush() + spans = [item.payload for item in items] + assert json.loads(spans[0]["attributes"][SPANDATA.GEN_AI_TOOL_DEFINITIONS]) == [ + { + "type": "function", + "name": "name", + "description": "description", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"}, + "state": {"type": "string"}, + }, + "required": ["city", "state"], + "additionalProperties": False, + }, + }, + { + "type": "custom", + "name": "name", + "description": "description", + }, + { + "type": "web_search", + }, + ] + elif stream_gen_ai_spans: + items = capture_items("span") + + with start_transaction(name="openai tx"): + client.responses.create( + model="gpt-4o", + input="How do I check if a Python object is an instance of a class?", + tools=[ + FunctionToolParam( + type="function", + name="name", + description="description", + parameters={ + "type": "object", + "properties": { + "city": {"type": "string"}, + "state": {"type": "string"}, + }, + "required": ["city", "state"], + "additionalProperties": False, + }, + strict=True, + ), + CustomToolParam( + type="custom", name="name", description="description" + ), + WebSearchToolParam(type="web_search"), + ], + ) + + spans = [item.payload for item in items] + assert json.loads(spans[0]["attributes"][SPANDATA.GEN_AI_TOOL_DEFINITIONS]) == [ + { + "type": "function", + "name": "name", + "description": "description", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"}, + "state": {"type": "string"}, + }, + "required": ["city", "state"], + "additionalProperties": False, + }, + }, + { + "type": "custom", + "name": "name", + "description": "description", + }, + { + "type": "web_search", + }, + ] + else: + events = capture_events() + + with start_transaction(name="openai tx"): + client.responses.create( + model="gpt-4o", + input="How do I check if a Python object is an instance of a class?", + tools=[ + FunctionToolParam( + type="function", + name="name", + description="description", + parameters={ + "type": "object", + "properties": { + "city": {"type": "string"}, + "state": {"type": "string"}, + }, + "required": ["city", "state"], + "additionalProperties": False, + }, + strict=True, + ), + CustomToolParam( + type="custom", name="name", description="description" + ), + WebSearchToolParam(type="web_search"), + ], + ) + + (transaction,) = events + spans = transaction["spans"] + + assert json.loads(spans[0]["data"][SPANDATA.GEN_AI_TOOL_DEFINITIONS]) == [ + { + "type": "function", + "name": "name", + "description": "description", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"}, + "state": {"type": "string"}, + }, + "required": ["city", "state"], + "additionalProperties": False, + }, + }, + { + "type": "custom", + "name": "name", + "description": "description", + }, + { + "type": "web_search", + }, + ] + + @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) @pytest.mark.parametrize(