diff --git a/agentops/instrumentation/openai/instrumentor.py b/agentops/instrumentation/openai/instrumentor.py index 3cf73e751..7b1a49096 100644 --- a/agentops/instrumentation/openai/instrumentor.py +++ b/agentops/instrumentation/openai/instrumentor.py @@ -24,13 +24,124 @@ """ from typing import List -from opentelemetry.trace import get_tracer +from opentelemetry.trace import get_tracer, SpanKind, Status, StatusCode from opentelemetry.instrumentation.openai.v1 import OpenAIV1Instrumentor as ThirdPartyOpenAIV1Instrumentor from agentops.logging import logger from agentops.instrumentation.common.wrappers import WrapConfig, wrap, unwrap from agentops.instrumentation.openai import LIBRARY_NAME, LIBRARY_VERSION from agentops.instrumentation.openai.attributes.common import get_response_attributes +from opentelemetry import context as context_api + + +def responses_wrapper(tracer, wrapped, instance, args, kwargs): + """Custom wrapper for OpenAI Responses API that checks for context from OpenAI Agents SDK""" + # Skip instrumentation if it's suppressed in the current context + if context_api.get_value("suppress_instrumentation"): + return wrapped(*args, **kwargs) + + return_value = None + + # Check if we have trace context from OpenAI Agents SDK + trace_id = context_api.get_value("openai_agents.trace_id", None) + parent_id = context_api.get_value("openai_agents.parent_id", None) + workflow_input = context_api.get_value("openai_agents.workflow_input", None) + + if trace_id: + logger.debug( + f"[OpenAI Instrumentor] Found OpenAI Agents trace context: trace_id={trace_id}, parent_id={parent_id}" + ) + + with tracer.start_as_current_span( + "openai.responses.create", + kind=SpanKind.CLIENT, + ) as span: + try: + attributes = get_response_attributes(args=args, kwargs=kwargs) + for key, value in attributes.items(): + span.set_attribute(key, value) + + # If we have trace context from OpenAI Agents SDK, add it as attributes + if trace_id: + span.set_attribute("openai_agents.trace_id", trace_id) + if parent_id: + span.set_attribute("openai_agents.parent_id", parent_id) + if workflow_input: + span.set_attribute("workflow.input", workflow_input) + + return_value = wrapped(*args, **kwargs) + + attributes = get_response_attributes(return_value=return_value) + for key, value in attributes.items(): + span.set_attribute(key, value) + + span.set_status(Status(StatusCode.OK)) + except Exception as e: + attributes = get_response_attributes(args=args, kwargs=kwargs, return_value=return_value) + for key, value in attributes.items(): + span.set_attribute(key, value) + + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR, str(e))) + raise + + return return_value + + +async def async_responses_wrapper(tracer, wrapped, instance, args, kwargs): + """Custom async wrapper for OpenAI Responses API that checks for context from OpenAI Agents SDK""" + # Skip instrumentation if it's suppressed in the current context + if context_api.get_value("suppress_instrumentation"): + return await wrapped(*args, **kwargs) + + return_value = None + + # Check if we have trace context from OpenAI Agents SDK + trace_id = context_api.get_value("openai_agents.trace_id", None) + parent_id = context_api.get_value("openai_agents.parent_id", None) + workflow_input = context_api.get_value("openai_agents.workflow_input", None) + + if trace_id: + logger.debug( + f"[OpenAI Instrumentor] Found OpenAI Agents trace context in async wrapper: trace_id={trace_id}, parent_id={parent_id}" + ) + + with tracer.start_as_current_span( + "openai.responses.create", + kind=SpanKind.CLIENT, + ) as span: + try: + # Add the input attributes to the span before execution + attributes = get_response_attributes(args=args, kwargs=kwargs) + for key, value in attributes.items(): + span.set_attribute(key, value) + + # If we have trace context from OpenAI Agents SDK, add it as attributes + if trace_id: + span.set_attribute("openai_agents.trace_id", trace_id) + if parent_id: + span.set_attribute("openai_agents.parent_id", parent_id) + if workflow_input: + span.set_attribute("workflow.input", workflow_input) + + return_value = await wrapped(*args, **kwargs) + + attributes = get_response_attributes(return_value=return_value) + for key, value in attributes.items(): + span.set_attribute(key, value) + + span.set_status(Status(StatusCode.OK)) + except Exception as e: + # Add everything we have in the case of an error + attributes = get_response_attributes(args=args, kwargs=kwargs, return_value=return_value) + for key, value in attributes.items(): + span.set_attribute(key, value) + + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR, str(e))) + raise + + return return_value # Methods to wrap beyond what the third-party instrumentation handles @@ -74,12 +185,35 @@ def _instrument(self, **kwargs): tracer_provider = kwargs.get("tracer_provider") tracer = get_tracer(LIBRARY_NAME, LIBRARY_VERSION, tracer_provider) - for wrap_config in WRAPPED_METHODS: - try: - wrap(wrap_config, tracer) - logger.debug(f"Successfully wrapped {wrap_config}") - except (AttributeError, ModuleNotFoundError) as e: - logger.debug(f"Failed to wrap {wrap_config}: {e}") + # Only use custom wrappers to avoid duplicate spans + from wrapt import wrap_function_wrapper + + try: + wrap_function_wrapper( + "openai.resources.responses", + "Responses.create", + lambda wrapped, instance, args, kwargs: responses_wrapper(tracer, wrapped, instance, args, kwargs), + ) + logger.debug("Successfully wrapped Responses.create with custom wrapper") + + wrap_function_wrapper( + "openai.resources.responses", + "AsyncResponses.create", + lambda wrapped, instance, args, kwargs: async_responses_wrapper( + tracer, wrapped, instance, args, kwargs + ), + ) + logger.debug("Successfully wrapped AsyncResponses.create with custom wrapper") + except (AttributeError, ModuleNotFoundError) as e: + logger.debug(f"Failed to wrap Responses API with custom wrapper: {e}") + + # Fall back to standard wrappers only if custom wrappers fail + for wrap_config in WRAPPED_METHODS: + try: + wrap(wrap_config, tracer) + logger.debug(f"Falling back to standard wrapper for {wrap_config}") + except (AttributeError, ModuleNotFoundError) as e: + logger.debug(f"Failed to wrap {wrap_config}: {e}") logger.debug("Successfully instrumented OpenAI API with Response extensions") @@ -87,11 +221,24 @@ def _uninstrument(self, **kwargs): """Remove instrumentation from OpenAI API.""" super()._uninstrument(**kwargs) - for wrap_config in WRAPPED_METHODS: - try: - unwrap(wrap_config) - logger.debug(f"Successfully unwrapped {wrap_config}") - except Exception as e: - logger.debug(f"Failed to unwrap {wrap_config}: {e}") + # First try to unwrap custom wrappers + from opentelemetry.instrumentation.utils import unwrap as _unwrap + + try: + _unwrap("openai.resources.responses.Responses", "create") + logger.debug("Successfully unwrapped Responses.create custom wrapper") + + _unwrap("openai.resources.responses.AsyncResponses", "create") + logger.debug("Successfully unwrapped AsyncResponses.create custom wrapper") + except Exception as e: + logger.debug(f"Failed to unwrap Responses API custom wrapper: {e}") + + # Fall back to standard unwrapping only if custom unwrapping fails + for wrap_config in WRAPPED_METHODS: + try: + unwrap(wrap_config) + logger.debug(f"Falling back to standard unwrapper for {wrap_config}") + except Exception as e: + logger.debug(f"Failed to unwrap {wrap_config}: {e}") logger.debug("Successfully removed OpenAI API instrumentation with Response extensions") diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index b06691021..5da8cb5e1 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -19,6 +19,7 @@ get_model_config_attributes, ) from agentops.instrumentation.openai_agents.attributes.completion import get_generation_output_attributes +from agentops.semconv import ToolAttributes # Attribute mapping for AgentSpanData @@ -33,7 +34,7 @@ # Attribute mapping for FunctionSpanData FUNCTION_SPAN_ATTRIBUTES: AttributeMap = { - AgentAttributes.AGENT_NAME: "name", + ToolAttributes.TOOL_NAME: "name", WorkflowAttributes.WORKFLOW_INPUT: "input", WorkflowAttributes.FINAL_OUTPUT: "output", AgentAttributes.FROM_AGENT: "from_agent", diff --git a/agentops/instrumentation/openai_agents/exporter.py b/agentops/instrumentation/openai_agents/exporter.py index 598f81c18..975c1b474 100644 --- a/agentops/instrumentation/openai_agents/exporter.py +++ b/agentops/instrumentation/openai_agents/exporter.py @@ -87,10 +87,13 @@ def get_span_name(span: Any) -> str: span_data = span.span_data span_type = span_data.__class__.__name__ + span_name = "" if hasattr(span_data, "name") and span_data.name: - return span_data.name + span_name = span_data.name else: - return span_type.replace("SpanData", "").lower() # fallback + span_name = span_type.replace("SpanData", "").lower() + + return span_name def _get_span_lookup_key(trace_id: str, span_id: str) -> str: @@ -303,6 +306,31 @@ def export_span(self, span: Any) -> None: trace_id = getattr(span, "trace_id", "unknown") parent_id = getattr(span, "parent_id", None) + # Special handling for ResponseSpanData to avoid duplicate spans + # and ensure proper trace hierarchy + if span_type == "ResponseSpanData": + logger.debug( + "[agentops.instrumentation.openai_agents] Processing ResponseSpanData for trace context propagation" + ) + + # Store the trace context information in a global context that can be accessed + # by the OpenAI instrumentation when it creates spans + ctx = context_api.get_current() + + # Store the OpenAI Agents trace context in the current context + ctx = context_api.set_value("openai_agents.trace_id", trace_id, ctx) + ctx = context_api.set_value("openai_agents.span_id", span_id, ctx) + ctx = context_api.set_value("openai_agents.parent_id", parent_id, ctx) + + if hasattr(span_data, "input") and span_data.input: + ctx = context_api.set_value("openai_agents.workflow_input", str(span_data.input), ctx) + context_api.attach(ctx) + + logger.debug( + f"[agentops.instrumentation.openai_agents] Propagated trace context: trace_id={trace_id}, parent_id={parent_id}" + ) + return + # Check if this is a span end event is_end_event = hasattr(span, "status") and span.status == StatusCode.OK.name diff --git a/tests/unit/instrumentation/openai_agents/test_openai_agents_attributes.py b/tests/unit/instrumentation/openai_agents/test_openai_agents_attributes.py index 1173b34af..36ffb9a8b 100644 --- a/tests/unit/instrumentation/openai_agents/test_openai_agents_attributes.py +++ b/tests/unit/instrumentation/openai_agents/test_openai_agents_attributes.py @@ -43,6 +43,7 @@ ) from agentops.semconv import ( + ToolAttributes, SpanAttributes, MessageAttributes, AgentAttributes, @@ -190,7 +191,7 @@ def test_function_span_attributes(self): attrs = get_function_span_attributes(mock_function_span) # Verify extracted attributes - note that complex objects should be serialized to strings - assert attrs[AgentAttributes.AGENT_NAME] == "test_function" + assert attrs[ToolAttributes.TOOL_NAME] == "test_function" assert attrs[WorkflowAttributes.WORKFLOW_INPUT] == '{"arg1": "value1"}' # Serialized string assert attrs[WorkflowAttributes.FINAL_OUTPUT] == '{"result": "success"}' # Serialized string assert attrs[AgentAttributes.FROM_AGENT] == "caller_agent" @@ -456,7 +457,7 @@ def __init__(self): assert AgentAttributes.AGENT_NAME in agent_attrs function_attrs = get_span_attributes(function_span) - assert AgentAttributes.AGENT_NAME in function_attrs + assert ToolAttributes.TOOL_NAME in function_attrs # Unknown span type should return empty dict unknown_attrs = get_span_attributes(unknown_span) diff --git a/tests/unit/instrumentation/openai_core/test_custom_wrappers.py b/tests/unit/instrumentation/openai_core/test_custom_wrappers.py new file mode 100644 index 000000000..22851ec76 --- /dev/null +++ b/tests/unit/instrumentation/openai_core/test_custom_wrappers.py @@ -0,0 +1,181 @@ +""" +Tests for OpenAI API Custom Wrappers + +This module contains tests for the custom wrappers used in the OpenAI instrumentor. +It verifies that our custom wrappers correctly handle context from OpenAI Agents SDK +and set the appropriate attributes on spans. +""" + +import pytest +from unittest.mock import MagicMock, patch + +from opentelemetry import context as context_api +from opentelemetry.trace import SpanKind, StatusCode + +from agentops.instrumentation.openai.instrumentor import ( + OpenAIInstrumentor, + responses_wrapper, + async_responses_wrapper, +) + + +class TestOpenAICustomWrappers: + """Tests for OpenAI API custom wrappers""" + + @pytest.fixture + def mock_tracer(self): + """Set up a mock tracer for testing""" + mock_tracer = MagicMock() + mock_span = MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span + return mock_tracer, mock_span + + @pytest.fixture + def mock_context(self): + """Set up a mock context with OpenAI Agents SDK trace information""" + # Mock the context_api.get_value method to return our test values + with patch.object(context_api, "get_value") as mock_get_value: + # Set up the mock to return different values based on the key + def side_effect(key, default=None, context=None): + if key == "openai_agents.trace_id": + return "test-trace-id" + elif key == "openai_agents.parent_id": + return "test-parent-id" + elif key == "openai_agents.workflow_input": + return "test workflow input" + elif key == "suppress_instrumentation": + return False + return default + + mock_get_value.side_effect = side_effect + yield mock_get_value + + def test_responses_wrapper_with_context(self, mock_tracer, mock_context): + """Test that the responses_wrapper correctly handles context from OpenAI Agents SDK""" + mock_tracer, mock_span = mock_tracer + + # Create a mock wrapped function + mock_wrapped = MagicMock() + mock_wrapped.return_value = {"id": "test-response-id", "model": "test-model"} + + # Set up mock get_response_attributes to return empty dict + with patch("agentops.instrumentation.openai.instrumentor.get_response_attributes", return_value={}): + # Call the wrapper + result = responses_wrapper(mock_tracer, mock_wrapped, None, [], {}) + + # Verify the wrapped function was called + assert mock_wrapped.called + + # Verify the span was created with the correct name and kind + mock_tracer.start_as_current_span.assert_called_once_with( + "openai.responses.create", + kind=SpanKind.CLIENT, + ) + + # Verify the context attributes were set on the span + mock_span.set_attribute.assert_any_call("openai_agents.trace_id", "test-trace-id") + mock_span.set_attribute.assert_any_call("openai_agents.parent_id", "test-parent-id") + mock_span.set_attribute.assert_any_call("workflow.input", "test workflow input") + + # Verify the status was set to OK + # Use assert_called to check that set_status was called, then check the status code + assert mock_span.set_status.called, "set_status was not called" + status_arg = mock_span.set_status.call_args[0][0] + assert status_arg.status_code == StatusCode.OK, f"Expected status code OK, got {status_arg.status_code}" + + # Verify the result was returned + assert result == {"id": "test-response-id", "model": "test-model"} + + @pytest.mark.asyncio + async def test_async_responses_wrapper_with_context(self, mock_tracer, mock_context): + """Test that the async_responses_wrapper correctly handles context from OpenAI Agents SDK""" + mock_tracer, mock_span = mock_tracer + + # Create a mock wrapped function that returns a coroutine + async def mock_async_func(*args, **kwargs): + return {"id": "test-response-id", "model": "test-model"} + + mock_wrapped = MagicMock() + mock_wrapped.side_effect = mock_async_func + + # Set up mock get_response_attributes to return empty dict + with patch("agentops.instrumentation.openai.instrumentor.get_response_attributes", return_value={}): + # Call the wrapper + result = await async_responses_wrapper(mock_tracer, mock_wrapped, None, [], {}) + + # Verify the wrapped function was called + assert mock_wrapped.called + + # Verify the span was created with the correct name and kind + mock_tracer.start_as_current_span.assert_called_once_with( + "openai.responses.create", + kind=SpanKind.CLIENT, + ) + + # Verify the context attributes were set on the span + mock_span.set_attribute.assert_any_call("openai_agents.trace_id", "test-trace-id") + mock_span.set_attribute.assert_any_call("openai_agents.parent_id", "test-parent-id") + mock_span.set_attribute.assert_any_call("workflow.input", "test workflow input") + + # Verify the status was set to OK + # Use assert_called to check that set_status was called, then check the status code + assert mock_span.set_status.called, "set_status was not called" + status_arg = mock_span.set_status.call_args[0][0] + assert status_arg.status_code == StatusCode.OK, f"Expected status code OK, got {status_arg.status_code}" + + # Verify the result was returned + assert result == {"id": "test-response-id", "model": "test-model"} + + def test_instrumentor_uses_custom_wrappers(self): + """Test that the instrumentor uses the custom wrappers""" + # Create instrumentor + instrumentor = OpenAIInstrumentor() + + # Mock wrap_function_wrapper + with patch("wrapt.wrap_function_wrapper") as mock_wrap_function_wrapper: + # Mock wrap to avoid errors + with patch("agentops.instrumentation.openai.instrumentor.wrap"): + # Mock the parent class's _instrument method to do nothing + with patch.object(instrumentor.__class__.__bases__[0], "_instrument"): + # Instrument + instrumentor._instrument(tracer_provider=MagicMock()) + + # Verify wrap_function_wrapper was called for both methods + assert mock_wrap_function_wrapper.call_count == 2 + + # Check the first call arguments for Responses.create + first_call_args = mock_wrap_function_wrapper.call_args_list[0] + assert first_call_args[0][0] == "openai.resources.responses" + assert first_call_args[0][1] == "Responses.create" + + # Check the second call arguments for AsyncResponses.create + second_call_args = mock_wrap_function_wrapper.call_args_list[1] + assert second_call_args[0][0] == "openai.resources.responses" + assert second_call_args[0][1] == "AsyncResponses.create" + + def test_instrumentor_unwraps_custom_wrappers(self): + """Test that the instrumentor unwraps the custom wrappers""" + # Create instrumentor + instrumentor = OpenAIInstrumentor() + + # Mock unwrap + with patch("opentelemetry.instrumentation.utils.unwrap") as mock_unwrap: + # Mock standard unwrap to avoid errors + with patch("agentops.instrumentation.openai.instrumentor.unwrap"): + # Mock the parent class's _uninstrument method to do nothing + with patch.object(instrumentor.__class__.__bases__[0], "_uninstrument"): + # Uninstrument + instrumentor._uninstrument() + + # Verify unwrap was called for both methods + assert mock_unwrap.call_count == 2 + + # Check the first call arguments for Responses.create + first_call_args = mock_unwrap.call_args_list[0] + assert first_call_args[0][0] == "openai.resources.responses.Responses" + assert first_call_args[0][1] == "create" + + # Check the second call arguments for AsyncResponses.create + second_call_args = mock_unwrap.call_args_list[1] + assert second_call_args[0][0] == "openai.resources.responses.AsyncResponses" + assert second_call_args[0][1] == "create" diff --git a/tests/unit/instrumentation/openai_core/test_instrumentor.py b/tests/unit/instrumentation/openai_core/test_instrumentor.py index ce364ed91..f636d664a 100644 --- a/tests/unit/instrumentation/openai_core/test_instrumentor.py +++ b/tests/unit/instrumentation/openai_core/test_instrumentor.py @@ -16,7 +16,6 @@ from agentops.instrumentation.openai.instrumentor import OpenAIInstrumentor -from agentops.instrumentation.common.wrappers import WrapConfig # Utility function to load fixtures @@ -46,6 +45,8 @@ def instrumentor(self): # objects are created before being used in the test mock_wrap = patch("agentops.instrumentation.openai.instrumentor.wrap").start() mock_unwrap = patch("agentops.instrumentation.openai.instrumentor.unwrap").start() + mock_wrap_function_wrapper = patch("wrapt.wrap_function_wrapper").start() + mock_unwrap_function = patch("opentelemetry.instrumentation.utils.unwrap").start() mock_instrument = patch.object(instrumentor, "_instrument", wraps=instrumentor._instrument).start() mock_uninstrument = patch.object(instrumentor, "_uninstrument", wraps=instrumentor._uninstrument).start() @@ -57,6 +58,8 @@ def instrumentor(self): "tracer_provider": mock_tracer_provider, "mock_wrap": mock_wrap, "mock_unwrap": mock_unwrap, + "mock_wrap_function_wrapper": mock_wrap_function_wrapper, + "mock_unwrap_function": mock_unwrap_function, "mock_instrument": mock_instrument, "mock_uninstrument": mock_uninstrument, } @@ -79,43 +82,66 @@ def test_instrumentor_initialization(self): def test_instrument_method_wraps_response_api(self, instrumentor): """Test the _instrument method wraps the Response API methods""" - mock_wrap = instrumentor["mock_wrap"] - - # Verify wrap was called for each method in WRAPPED_METHODS - assert mock_wrap.call_count == 2 - - # Check the first call arguments for Responses.create - first_call_args = mock_wrap.call_args_list[0][0] - assert isinstance(first_call_args[0], WrapConfig) - assert first_call_args[0].trace_name == "openai.responses.create" - assert first_call_args[0].package == "openai.resources.responses" - assert first_call_args[0].class_name == "Responses" - assert first_call_args[0].method_name == "create" - - # Check the second call arguments for AsyncResponses.create - second_call_args = mock_wrap.call_args_list[1][0] - assert isinstance(second_call_args[0], WrapConfig) - assert second_call_args[0].trace_name == "openai.responses.create" - assert second_call_args[0].package == "openai.resources.responses" - assert second_call_args[0].class_name == "AsyncResponses" - assert second_call_args[0].method_name == "create" + # We're now using wrap_function_wrapper instead of wrap, so we need to check that + mock_wrap_function_wrapper = instrumentor.get("mock_wrap_function_wrapper", None) + + # If mock_wrap_function_wrapper is not in the fixture, patch it now + if mock_wrap_function_wrapper is None: + with patch("wrapt.wrap_function_wrapper") as mock_wrap_function_wrapper: + # Re-instrument to trigger the wrap_function_wrapper calls + instrumentor_obj = instrumentor["instrumentor"] + instrumentor_obj._instrument(tracer_provider=instrumentor["tracer_provider"]) + + # Verify wrap_function_wrapper was called twice + assert mock_wrap_function_wrapper.call_count == 2 + + # Check the first call arguments for Responses.create + first_call_args = mock_wrap_function_wrapper.call_args_list[0][0] + assert first_call_args[0] == "openai.resources.responses" + assert first_call_args[1] == "Responses.create" + + # Check the second call arguments for AsyncResponses.create + second_call_args = mock_wrap_function_wrapper.call_args_list[1][0] + assert second_call_args[0] == "openai.resources.responses" + assert second_call_args[1] == "AsyncResponses.create" + else: + # Verify wrap_function_wrapper was called twice + assert mock_wrap_function_wrapper.call_count == 2 + + # Check the first call arguments for Responses.create + first_call_args = mock_wrap_function_wrapper.call_args_list[0][0] + assert first_call_args[0] == "openai.resources.responses" + assert first_call_args[1] == "Responses.create" + + # Check the second call arguments for AsyncResponses.create + second_call_args = mock_wrap_function_wrapper.call_args_list[1][0] + assert second_call_args[0] == "openai.resources.responses" + assert second_call_args[1] == "AsyncResponses.create" def test_uninstrument_method_unwraps_response_api(self, instrumentor): """Test the _uninstrument method unwraps the Response API methods""" - # For these tests, we'll manually call the unwrap method with the expected configs - # since the fixture setup has been changed - - instrumentor_obj = instrumentor["instrumentor"] + # We're now using _unwrap instead of unwrap, so we need to check that + mock_unwrap_function = instrumentor["mock_unwrap_function"] # Reset the mock to clear any previous calls - mock_unwrap = instrumentor["mock_unwrap"] - mock_unwrap.reset_mock() + mock_unwrap_function.reset_mock() # Call the uninstrument method directly + instrumentor_obj = instrumentor["instrumentor"] instrumentor_obj._uninstrument() # Now verify the method was called - assert mock_unwrap.called, "unwrap was not called during _uninstrument" + assert mock_unwrap_function.call_count >= 2, "unwrap was not called during _uninstrument" + + # Check the first call arguments for Responses.create + first_call_args = mock_unwrap_function.call_args_list[0][0] + assert first_call_args[0] == "openai.resources.responses.Responses" + assert first_call_args[1] == "create" + + # Check the second call arguments for AsyncResponses.create + second_call_args = mock_unwrap_function.call_args_list[1][0] + assert second_call_args[0] == "openai.resources.responses.AsyncResponses" + assert second_call_args[1] == "create" def test_calls_parent_instrument(self, instrumentor): """Test that the instrumentor calls the parent class's _instrument method"""