Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions sentry_sdk/ai/_openai_responses_api.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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",
Comment thread
alexander-alderman-webb marked this conversation as resolved.
}

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"]
Comment thread
alexander-alderman-webb marked this conversation as resolved.

tool_definitions.append(tool_definition)
continue

tool_definitions.append({"type": tool["type"]})

return tool_definitions
18 changes: 11 additions & 7 deletions sentry_sdk/integrations/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -46,7 +49,6 @@
capture_internal_exceptions,
event_from_exception,
reraise,
safe_serialize,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -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)
Expand Down
194 changes: 194 additions & 0 deletions tests/integrations/openai/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading