Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import List, Optional, Union

from opentelemetry import context as context_api
import pydantic
from opentelemetry.instrumentation.openai.shared import (
OPENAI_LLM_USAGE_TOKEN_TYPES,
_get_openai_base_url,
Expand Down Expand Up @@ -50,9 +51,6 @@
from opentelemetry.trace.status import Status, StatusCode
from wrapt import ObjectProxy

from openai.types.chat import ChatCompletionMessageToolCall
from openai.types.chat.chat_completion_message import FunctionCall

SPAN_NAME = "openai.chat"
PROMPT_FILTER_KEY = "prompt_filter_results"
CONTENT_FILTER_KEY = "content_filter_results"
Expand Down Expand Up @@ -961,8 +959,10 @@ async def _abuild_from_streaming_response(
span.end()


# pydantic.BaseModel here is ChatCompletionMessageFunctionToolCall (as of openai 1.99.7)
# but we keep to a parent type to support older versions
def _parse_tool_calls(
tool_calls: Optional[List[Union[dict, ChatCompletionMessageToolCall]]],
tool_calls: Optional[List[Union[dict, pydantic.BaseModel]]],
) -> Union[List[ToolCall], None]:
"""
Util to correctly parse the tool calls data from the OpenAI API to this module's
Expand All @@ -976,12 +976,11 @@ def _parse_tool_calls(
for tool_call in tool_calls:
tool_call_data = None

# Handle dict or ChatCompletionMessageToolCall
if isinstance(tool_call, dict):
tool_call_data = copy.deepcopy(tool_call)
elif isinstance(tool_call, ChatCompletionMessageToolCall):
elif _is_chat_message_function_tool_call(tool_call):
tool_call_data = tool_call.model_dump()
elif isinstance(tool_call, FunctionCall):
elif _is_function_call(tool_call):
function_call = tool_call.model_dump()
tool_call_data = ToolCall(
id="",
Expand All @@ -996,6 +995,34 @@ def _parse_tool_calls(
return result


def _is_chat_message_function_tool_call(model: Union[dict, pydantic.BaseModel]) -> bool:
try:
from openai.types.chat.chat_completion_message_function_tool_call import (
ChatCompletionMessageFunctionToolCall,
)

return isinstance(model, ChatCompletionMessageFunctionToolCall)
except Exception:
try:
# Since OpenAI 1.99.3, ChatCompletionMessageToolCall is a Union,
# and the isinstance check will fail. This is fine, because in all
# those versions, the check above will succeed.
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
)
return isinstance(model, ChatCompletionMessageToolCall)
except Exception:
return False


def _is_function_call(model: Union[dict, pydantic.BaseModel]) -> bool:
try:
from openai.types.chat.chat_completion_message import FunctionCall
return isinstance(model, FunctionCall)
except Exception:
return False


@singledispatch
def _parse_choice_event(choice) -> ChoiceEvent:
has_message = choice.message is not None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,14 @@ def responses_get_or_create_wrapper(tracer: Tracer, wrapped, instance, args, kwa
merged_tools = existing_data.get("tools", []) + request_tools

try:
parsed_response_output_text = None
if hasattr(parsed_response, "output_text"):
parsed_response_output_text = parsed_response.output_text
else:
try:
parsed_response_output_text = parsed_response.output[0].content[0].text
except Exception:
pass
traced_data = TracedData(
Comment on lines +450 to 458

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add array bounds validation for safer nested access

The fallback logic parsed_response.output[0].content[0].text could raise IndexError if either array is empty. Consider adding bounds checking for more robust error handling.

 parsed_response_output_text = None
 if hasattr(parsed_response, "output_text"):
     parsed_response_output_text = parsed_response.output_text
 else:
     try:
-        parsed_response_output_text = parsed_response.output[0].content[0].text
+        if (parsed_response.output 
+            and len(parsed_response.output) > 0 
+            and parsed_response.output[0].content 
+            and len(parsed_response.output[0].content) > 0):
+            parsed_response_output_text = parsed_response.output[0].content[0].text
     except Exception:
         pass
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
parsed_response_output_text = None
if hasattr(parsed_response, "output_text"):
parsed_response_output_text = parsed_response.output_text
else:
try:
parsed_response_output_text = parsed_response.output[0].content[0].text
except Exception:
pass
traced_data = TracedData(
parsed_response_output_text = None
if hasattr(parsed_response, "output_text"):
parsed_response_output_text = parsed_response.output_text
else:
try:
if (parsed_response.output
and len(parsed_response.output) > 0
and parsed_response.output[0].content
and len(parsed_response.output[0].content) > 0):
parsed_response_output_text = parsed_response.output[0].content[0].text
except Exception:
pass
traced_data = TracedData(
🧰 Tools
🪛 Ruff (0.12.2)

454-457: Use contextlib.suppress(Exception) instead of try-except-pass

Replace with contextlib.suppress(Exception)

(SIM105)

🤖 Prompt for AI Agents
In
packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/v1/responses_wrappers.py
around lines 450 to 458, the fallback access
parsed_response.output[0].content[0].text can raise IndexError if output or
content arrays are empty; add explicit bounds checks (e.g. verify
parsed_response.output is a list-like and len(parsed_response.output) > 0 and
parsed_response.output[0].content is list-like and len(...) > 0) before
indexing, and narrow the exception handling to IndexError/AttributeError (or use
conditional attribute/getattr checks) so parsed_response_output_text safely
falls back to None without swallowing unrelated exceptions.

start_time=existing_data.get("start_time", start_time),
response_id=parsed_response.id,
Expand All @@ -456,7 +464,7 @@ def responses_get_or_create_wrapper(tracer: Tracer, wrapped, instance, args, kwa
output_blocks={block.id: block for block in parsed_response.output}
| existing_data.get("output_blocks", {}),
usage=existing_data.get("usage", parsed_response.usage),
output_text=existing_data.get("output_text", parsed_response.output_text),
output_text=existing_data.get("output_text", parsed_response_output_text),
request_model=existing_data.get("request_model", kwargs.get("model")),
response_model=existing_data.get("response_model", parsed_response.model),
)
Expand Down Expand Up @@ -541,6 +549,15 @@ async def async_responses_get_or_create_wrapper(
merged_tools = existing_data.get("tools", []) + request_tools

try:
parsed_response_output_text = None
if hasattr(parsed_response, "output_text"):
parsed_response_output_text = parsed_response.output_text
else:
try:
parsed_response_output_text = parsed_response.output[0].content[0].text
except Exception:
pass

traced_data = TracedData(
start_time=existing_data.get("start_time", start_time),
response_id=parsed_response.id,
Expand All @@ -550,7 +567,7 @@ async def async_responses_get_or_create_wrapper(
output_blocks={block.id: block for block in parsed_response.output}
| existing_data.get("output_blocks", {}),
usage=existing_data.get("usage", parsed_response.usage),
output_text=existing_data.get("output_text", parsed_response.output_text),
output_text=existing_data.get("output_text", parsed_response_output_text),
request_model=existing_data.get("request_model", kwargs.get("model")),
response_model=existing_data.get("response_model", parsed_response.model),
)
Expand Down
12 changes: 6 additions & 6 deletions packages/opentelemetry-instrumentation-openai/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pytest = "^8.2.2"
pytest-sugar = "1.0.0"
vcrpy = "^6.0.1"
pytest-recording = "^0.13.1"
openai = { extras = ["datalib"], version = ">=1.66.0" }
openai = { extras = ["datalib"], version = "1.99.7" }
opentelemetry-sdk = "^1.27.0"
pytest-asyncio = "^0.23.7"
requests = "^2.31.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
import httpx
import pytest
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
Function,
ChatCompletionMessageFunctionToolCall,
)
from opentelemetry.sdk._logs import LogData
from opentelemetry.semconv._incubating.attributes import (
Expand Down Expand Up @@ -375,13 +374,21 @@ def test_chat_tool_calls_with_events_with_no_content(
def test_chat_pydantic_based_tool_calls(
instrument_legacy, span_exporter, log_exporter, openai_client
):
try:
Comment thread
nirga marked this conversation as resolved.
from openai.types.chat.chat_completion_message_function_tool_call import Function
except (ImportError, ModuleNotFoundError, AttributeError):
try:
from openai.types.chat.chat_completion_message_tool_call import Function
except (ImportError, ModuleNotFoundError, AttributeError):
pytest.skip("Could not import Function. Please check your OpenAI version. Skipping test.")

openai_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{
"role": "assistant",
"tool_calls": [
ChatCompletionMessageToolCall(
ChatCompletionMessageFunctionToolCall(
id="1",
type="function",
function=Function(
Expand Down Expand Up @@ -440,13 +447,21 @@ def test_chat_pydantic_based_tool_calls(
def test_chat_pydantic_based_tool_calls_with_events_with_content(
instrument_with_content, span_exporter, log_exporter, openai_client
):
try:
from openai.types.chat.chat_completion_message_function_tool_call import Function
except (ImportError, ModuleNotFoundError, AttributeError):
try:
from openai.types.chat.chat_completion_message_tool_call import Function
except (ImportError, ModuleNotFoundError, AttributeError):
pytest.skip("Could not import Function. Please check your OpenAI version. Skipping test.")

openai_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{
"role": "assistant",
"tool_calls": [
ChatCompletionMessageToolCall(
ChatCompletionMessageFunctionToolCall(
id="1",
type="function",
function=Function(
Expand Down Expand Up @@ -518,13 +533,21 @@ def test_chat_pydantic_based_tool_calls_with_events_with_content(
def test_chat_pydantic_based_tool_calls_with_events_with_no_content(
instrument_with_no_content, span_exporter, log_exporter, openai_client
):
try:
from openai.types.chat.chat_completion_message_function_tool_call import Function
except (ImportError, ModuleNotFoundError, AttributeError):
try:
from openai.types.chat.chat_completion_message_tool_call import Function
except (ImportError, ModuleNotFoundError, AttributeError):
pytest.skip("Could not import Function. Please check your OpenAI version. Skipping test.")

openai_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{
"role": "assistant",
"tool_calls": [
ChatCompletionMessageToolCall(
ChatCompletionMessageFunctionToolCall(
id="1",
type="function",
function=Function(
Expand Down Expand Up @@ -951,7 +974,6 @@ async def test_chat_async_streaming_with_events_with_no_content(


@pytest.mark.vcr
@pytest.mark.asyncio
def test_with_asyncio_run(
instrument_legacy, span_exporter, log_exporter, async_openai_client
):
Expand Down Expand Up @@ -981,7 +1003,6 @@ def test_with_asyncio_run(


@pytest.mark.vcr
@pytest.mark.asyncio
def test_with_asyncio_run_with_events_with_content(
instrument_with_content, span_exporter, log_exporter, async_openai_client
):
Expand Down Expand Up @@ -1030,7 +1051,6 @@ def test_with_asyncio_run_with_events_with_content(


@pytest.mark.vcr
@pytest.mark.asyncio
def test_with_asyncio_run_with_events_with_no_content(
instrument_with_no_content, span_exporter, log_exporter, async_openai_client
):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def test_responses(instrument_legacy, span_exporter: InMemorySpanExporter, opena
span.attributes["gen_ai.prompt.0.content"] == "What is the capital of France?"
)
assert span.attributes["gen_ai.prompt.0.role"] == "user"
assert span.attributes["gen_ai.completion.0.content"] == response.output_text
assert span.attributes["gen_ai.completion.0.content"] == response.output[0].content[0].text
assert span.attributes["gen_ai.completion.0.role"] == "assistant"


Expand All @@ -46,7 +46,7 @@ def test_responses_with_input_history(instrument_legacy, span_exporter: InMemory
"content": [
{
"type": "output_text",
"text": first_response.output_text,
"text": first_response.output[0].content[0].text,
}
],
},
Expand All @@ -69,7 +69,7 @@ def test_responses_with_input_history(instrument_legacy, span_exporter: InMemory
assert json.loads(span.attributes["gen_ai.prompt.1.content"]) == [
{
"type": "output_text",
"text": first_response.output_text,
"text": first_response.output[0].content[0].text,
}
]
assert span.attributes["gen_ai.prompt.1.role"] == "assistant"
Expand All @@ -78,7 +78,7 @@ def test_responses_with_input_history(instrument_legacy, span_exporter: InMemory
== "Can you explain why you chose that word?"
)
assert span.attributes["gen_ai.prompt.2.role"] == "user"
assert span.attributes["gen_ai.completion.0.content"] == response.output_text
assert span.attributes["gen_ai.completion.0.content"] == response.output[0].content[0].text
assert span.attributes["gen_ai.completion.0.role"] == "assistant"


Expand Down