diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 6c8510380a8..519b90efc70 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -955,7 +955,14 @@ def _emit_once(self, kwargs: dict, *scope: object) -> bool: spans_logged = {} _otel_internal["spans_logged"] = spans_logged - dedupe_key = (self.__class__.__name__, id(self), *scope) + # Normalise scope parts so they are always hashable. `guardrail_mode` + # can be a list (e.g. ["pre_call", "post_call"]) and a tuple containing + # a list is itself unhashable, causing a TypeError when used as a dict key. + normalized_scope = tuple( + tuple(part) if isinstance(part, list) else part + for part in scope + ) + dedupe_key = (self.__class__.__name__, id(self), *normalized_scope) if spans_logged.get(dedupe_key) is True: return False diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index b65e629c890..4fdc17aaad9 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -87,6 +87,38 @@ def test_create_guardrail_span_with_no_info(self): # Verify that start_span was never called otel.tracer.start_span.assert_not_called() + @patch("litellm.integrations.opentelemetry.datetime") + def test_guardrail_mode_as_list_does_not_crash(self, mock_datetime): + """Regression test for https://github.com/BerriAI/litellm/issues/28486. + + When a guardrail is configured with a list ``mode`` (e.g. + ``["pre_call", "post_call"]``), ``_create_guardrail_span`` previously + crashed with ``TypeError: unhashable type: 'list'`` inside + ``_emit_once``, because the list value was unpacked directly into the + tuple used as a ``dict`` key. Lists are not hashable; only tuples are. + + The fix normalises each scope part in ``_emit_once`` to a tuple when + it is a list, so the dedupe key is always hashable. + """ + otel = OpenTelemetry() + otel.tracer = MagicMock() + mock_span = MagicMock() + otel.tracer.start_span.return_value = mock_span + + guardrail_info = { + "guardrail_name": "test_guardrail", + "guardrail_mode": ["pre_call", "post_call"], # list-valued mode + "start_time": 1609459200.0, + "end_time": 1609459201.0, + } + kwargs = { + "standard_logging_object": {"guardrail_information": [guardrail_info]} + } + + # Must not raise TypeError: unhashable type: 'list' + otel._create_guardrail_span(kwargs=kwargs, context=None) + otel.tracer.start_span.assert_called_once() + @patch("litellm.integrations.opentelemetry.datetime") def test_guardrail_response_dict_is_json_serialized(self, mock_datetime): """Dict guardrail_response (e.g. OpenAI moderation result) must reach