diff --git a/sentry_sdk/integrations/litellm.py b/sentry_sdk/integrations/litellm.py index 3cff0fbc23..3f3cde8acc 100644 --- a/sentry_sdk/integrations/litellm.py +++ b/sentry_sdk/integrations/litellm.py @@ -1,4 +1,3 @@ -import copy from typing import TYPE_CHECKING import sentry_sdk @@ -8,7 +7,6 @@ get_start_span_function, set_data_normalized, truncate_and_annotate_messages, - transform_openai_content_part, truncate_and_annotate_embedding_inputs, ) from sentry_sdk.consts import SPANDATA @@ -17,7 +15,7 @@ from sentry_sdk.utils import event_from_exception if TYPE_CHECKING: - from typing import Any, Dict, List + from typing import Any, Dict from datetime import datetime try: @@ -39,33 +37,6 @@ def _get_metadata_dict(kwargs: "Dict[str, Any]") -> "Dict[str, Any]": return metadata -def _convert_message_parts(messages: "List[Dict[str, Any]]") -> "List[Dict[str, Any]]": - """ - Convert the message parts from OpenAI format to the `gen_ai.request.messages` format - using the OpenAI-specific transformer (LiteLLM uses OpenAI's message format). - - Deep copies messages to avoid mutating original kwargs. - """ - # Deep copy to avoid mutating original messages from kwargs - messages = copy.deepcopy(messages) - - for message in messages: - if not isinstance(message, dict): - continue - content = message.get("content") - if isinstance(content, (list, tuple)): - transformed = [] - for item in content: - if isinstance(item, dict): - result = transform_openai_content_part(item) - # If transformation succeeded, use the result; otherwise keep original - transformed.append(result if result is not None else item) - else: - transformed.append(item) - message["content"] = transformed - return messages - - def _input_callback(kwargs: "Dict[str, Any]") -> None: """Handle the start of a request.""" integration = sentry_sdk.get_client().get_integration(LiteLLMIntegration) @@ -134,7 +105,6 @@ def _input_callback(kwargs: "Dict[str, Any]") -> None: messages = kwargs.get("messages", []) if messages: scope = sentry_sdk.get_current_scope() - messages = _convert_message_parts(messages) messages_data = truncate_and_annotate_messages(messages, span, scope) if messages_data is not None: set_data_normalized( diff --git a/tests/integrations/litellm/test_litellm.py b/tests/integrations/litellm/test_litellm.py index 90807744e7..bda35805c6 100644 --- a/tests/integrations/litellm/test_litellm.py +++ b/tests/integrations/litellm/test_litellm.py @@ -1,4 +1,3 @@ -import base64 import json import pytest import time @@ -22,10 +21,8 @@ async def __call__(self, *args, **kwargs): from sentry_sdk import start_transaction from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk._types import BLOB_DATA_SUBSTITUTE from sentry_sdk.integrations.litellm import ( LiteLLMIntegration, - _convert_message_parts, _input_callback, _success_callback, _failure_callback, @@ -1441,551 +1438,3 @@ def test_litellm_message_truncation(sentry_init, capture_items): tx = next(item.payload for item in items if item.type == "transaction") assert tx["_meta"]["spans"]["0"]["data"]["gen_ai.request.messages"][""]["len"] == 5 - - -IMAGE_DATA = b"fake_image_data_12345" -IMAGE_B64 = base64.b64encode(IMAGE_DATA).decode("utf-8") -IMAGE_DATA_URI = f"data:image/png;base64,{IMAGE_B64}" - - -def test_binary_content_encoding_image_url( - reset_litellm_executor, - sentry_init, - capture_items, - get_model_response, - nonstreaming_chat_completions_model_response, -): - sentry_init( - integrations=[LiteLLMIntegration(include_prompts=True)], - traces_sample_rate=1.0, - send_default_pii=True, - ) - items = capture_items("transaction", "span") - - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "Look at this image:"}, - { - "type": "image_url", - "image_url": {"url": IMAGE_DATA_URI, "detail": "high"}, - }, - ], - } - ] - client = OpenAI(api_key="test-key") - - model_response = get_model_response( - nonstreaming_chat_completions_model_response, - serialize_pydantic=True, - request_headers={"X-Stainless-Raw-Response": "true"}, - ) - - with mock.patch.object( - client.completions._client._client, - "send", - return_value=model_response, - ): - with start_transaction(name="litellm test"): - litellm.completion( - model="gpt-4-vision-preview", - messages=messages, - client=client, - custom_llm_provider="openai", - ) - - litellm_utils.executor.shutdown(wait=True) - - spans = [item.payload for item in items if item.type == "span"] - chat_spans = list( - x - for x in spans - if x["attributes"]["sentry.op"] == OP.GEN_AI_CHAT - and x["attributes"]["sentry.origin"] == "auto.ai.litellm" - ) - assert len(chat_spans) == 1 - span = chat_spans[0] - messages_data = json.loads(span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES]) - - blob_item = next( - ( - item - for msg in messages_data - if "content" in msg - for item in msg["content"] - if item.get("type") == "blob" - ), - None, - ) - assert blob_item is not None - assert blob_item["modality"] == "image" - assert blob_item["mime_type"] == "image/png" - assert ( - IMAGE_B64 in blob_item["content"] - or blob_item["content"] == BLOB_DATA_SUBSTITUTE - ) - - -@pytest.mark.asyncio(loop_scope="session") -async def test_async_binary_content_encoding_image_url( - sentry_init, - capture_items, - get_model_response, - nonstreaming_chat_completions_model_response, -): - sentry_init( - integrations=[LiteLLMIntegration(include_prompts=True)], - traces_sample_rate=1.0, - send_default_pii=True, - ) - items = capture_items("transaction", "span") - - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "Look at this image:"}, - { - "type": "image_url", - "image_url": {"url": IMAGE_DATA_URI, "detail": "high"}, - }, - ], - } - ] - client = AsyncOpenAI(api_key="test-key") - - model_response = get_model_response( - nonstreaming_chat_completions_model_response, - serialize_pydantic=True, - request_headers={"X-Stainless-Raw-Response": "true"}, - ) - - with mock.patch.object( - client.completions._client._client, - "send", - return_value=model_response, - ): - with start_transaction(name="litellm test"): - await litellm.acompletion( - model="gpt-4-vision-preview", - messages=messages, - client=client, - custom_llm_provider="openai", - ) - - await GLOBAL_LOGGING_WORKER.flush() - await asyncio.sleep(0.5) - - spans = [item.payload for item in items if item.type == "span"] - chat_spans = list( - x - for x in spans - if x["attributes"]["sentry.op"] == OP.GEN_AI_CHAT - and x["attributes"]["sentry.origin"] == "auto.ai.litellm" - ) - assert len(chat_spans) == 1 - span = chat_spans[0] - messages_data = json.loads(span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES]) - - blob_item = next( - ( - item - for msg in messages_data - if "content" in msg - for item in msg["content"] - if item.get("type") == "blob" - ), - None, - ) - assert blob_item is not None - assert blob_item["modality"] == "image" - assert blob_item["mime_type"] == "image/png" - assert ( - IMAGE_B64 in blob_item["content"] - or blob_item["content"] == BLOB_DATA_SUBSTITUTE - ) - - -def test_binary_content_encoding_mixed_content( - reset_litellm_executor, - sentry_init, - capture_items, - get_model_response, - nonstreaming_chat_completions_model_response, -): - sentry_init( - integrations=[LiteLLMIntegration(include_prompts=True)], - traces_sample_rate=1.0, - send_default_pii=True, - ) - items = capture_items("transaction", "span") - - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "Here is an image:"}, - { - "type": "image_url", - "image_url": {"url": IMAGE_DATA_URI}, - }, - {"type": "text", "text": "What do you see?"}, - ], - } - ] - client = OpenAI(api_key="test-key") - - model_response = get_model_response( - nonstreaming_chat_completions_model_response, - serialize_pydantic=True, - request_headers={"X-Stainless-Raw-Response": "true"}, - ) - - with mock.patch.object( - client.completions._client._client, - "send", - return_value=model_response, - ): - with start_transaction(name="litellm test"): - litellm.completion( - model="gpt-4-vision-preview", - messages=messages, - client=client, - custom_llm_provider="openai", - ) - - litellm_utils.executor.shutdown(wait=True) - - spans = [item.payload for item in items if item.type == "span"] - chat_spans = list( - x - for x in spans - if x["attributes"]["sentry.op"] == OP.GEN_AI_CHAT - and x["attributes"]["sentry.origin"] == "auto.ai.litellm" - ) - assert len(chat_spans) == 1 - span = chat_spans[0] - messages_data = json.loads(span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES]) - - content_items = [ - item for msg in messages_data if "content" in msg for item in msg["content"] - ] - assert any(item.get("type") == "text" for item in content_items) - assert any(item.get("type") == "blob" for item in content_items) - - -@pytest.mark.asyncio(loop_scope="session") -async def test_async_binary_content_encoding_mixed_content( - sentry_init, - capture_items, - get_model_response, - nonstreaming_chat_completions_model_response, -): - sentry_init( - integrations=[LiteLLMIntegration(include_prompts=True)], - traces_sample_rate=1.0, - send_default_pii=True, - ) - items = capture_items("transaction", "span") - - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "Here is an image:"}, - { - "type": "image_url", - "image_url": {"url": IMAGE_DATA_URI}, - }, - {"type": "text", "text": "What do you see?"}, - ], - } - ] - client = AsyncOpenAI(api_key="test-key") - - model_response = get_model_response( - nonstreaming_chat_completions_model_response, - serialize_pydantic=True, - request_headers={"X-Stainless-Raw-Response": "true"}, - ) - - with mock.patch.object( - client.completions._client._client, - "send", - return_value=model_response, - ): - with start_transaction(name="litellm test"): - await litellm.acompletion( - model="gpt-4-vision-preview", - messages=messages, - client=client, - custom_llm_provider="openai", - ) - - await GLOBAL_LOGGING_WORKER.flush() - await asyncio.sleep(0.5) - - spans = [item.payload for item in items if item.type == "span"] - chat_spans = list( - x - for x in spans - if x["attributes"]["sentry.op"] == OP.GEN_AI_CHAT - and x["attributes"]["sentry.origin"] == "auto.ai.litellm" - ) - assert len(chat_spans) == 1 - span = chat_spans[0] - messages_data = json.loads(span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES]) - - content_items = [ - item for msg in messages_data if "content" in msg for item in msg["content"] - ] - assert any(item.get("type") == "text" for item in content_items) - assert any(item.get("type") == "blob" for item in content_items) - - -def test_binary_content_encoding_uri_type( - reset_litellm_executor, - sentry_init, - capture_items, - get_model_response, - nonstreaming_chat_completions_model_response, -): - sentry_init( - integrations=[LiteLLMIntegration(include_prompts=True)], - traces_sample_rate=1.0, - send_default_pii=True, - ) - items = capture_items("transaction", "span") - - messages = [ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": {"url": "https://example.com/image.jpg"}, - } - ], - } - ] - client = OpenAI(api_key="test-key") - - model_response = get_model_response( - nonstreaming_chat_completions_model_response, - serialize_pydantic=True, - request_headers={"X-Stainless-Raw-Response": "true"}, - ) - - with mock.patch.object( - client.completions._client._client, - "send", - return_value=model_response, - ): - with start_transaction(name="litellm test"): - litellm.completion( - model="gpt-4-vision-preview", - messages=messages, - client=client, - custom_llm_provider="openai", - ) - - litellm_utils.executor.shutdown(wait=True) - - spans = [item.payload for item in items if item.type == "span"] - chat_spans = list( - x - for x in spans - if x["attributes"]["sentry.op"] == OP.GEN_AI_CHAT - and x["attributes"]["sentry.origin"] == "auto.ai.litellm" - ) - assert len(chat_spans) == 1 - span = chat_spans[0] - messages_data = json.loads(span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES]) - - uri_item = next( - ( - item - for msg in messages_data - if "content" in msg - for item in msg["content"] - if item.get("type") == "uri" - ), - None, - ) - assert uri_item is not None - assert uri_item["uri"] == "https://example.com/image.jpg" - - -@pytest.mark.asyncio(loop_scope="session") -async def test_async_binary_content_encoding_uri_type( - sentry_init, - capture_items, - get_model_response, - nonstreaming_chat_completions_model_response, -): - sentry_init( - integrations=[LiteLLMIntegration(include_prompts=True)], - traces_sample_rate=1.0, - send_default_pii=True, - ) - items = capture_items("transaction", "span") - - messages = [ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": {"url": "https://example.com/image.jpg"}, - } - ], - } - ] - client = AsyncOpenAI(api_key="test-key") - - model_response = get_model_response( - nonstreaming_chat_completions_model_response, - serialize_pydantic=True, - request_headers={"X-Stainless-Raw-Response": "true"}, - ) - - with mock.patch.object( - client.completions._client._client, - "send", - return_value=model_response, - ): - with start_transaction(name="litellm test"): - await litellm.acompletion( - model="gpt-4-vision-preview", - messages=messages, - client=client, - custom_llm_provider="openai", - ) - - await GLOBAL_LOGGING_WORKER.flush() - await asyncio.sleep(0.5) - - spans = [item.payload for item in items if item.type == "span"] - chat_spans = list( - x - for x in spans - if x["attributes"]["sentry.op"] == OP.GEN_AI_CHAT - and x["attributes"]["sentry.origin"] == "auto.ai.litellm" - ) - assert len(chat_spans) == 1 - span = chat_spans[0] - messages_data = json.loads(span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES]) - - uri_item = next( - ( - item - for msg in messages_data - if "content" in msg - for item in msg["content"] - if item.get("type") == "uri" - ), - None, - ) - assert uri_item is not None - assert uri_item["uri"] == "https://example.com/image.jpg" - - -def test_convert_message_parts_direct(): - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "Hello"}, - { - "type": "image_url", - "image_url": {"url": IMAGE_DATA_URI}, - }, - ], - } - ] - converted = _convert_message_parts(messages) - blob_item = next( - item for item in converted[0]["content"] if item.get("type") == "blob" - ) - assert blob_item["modality"] == "image" - assert blob_item["mime_type"] == "image/png" - assert IMAGE_B64 in blob_item["content"] - - -def test_convert_message_parts_does_not_mutate_original(): - """Ensure _convert_message_parts does not mutate the original messages.""" - original_url = IMAGE_DATA_URI - messages = [ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": {"url": original_url}, - }, - ], - } - ] - _convert_message_parts(messages) - # Original should be unchanged - assert messages[0]["content"][0]["type"] == "image_url" - assert messages[0]["content"][0]["image_url"]["url"] == original_url - - -def test_convert_message_parts_data_url_without_base64(): - """Data URLs without ;base64, marker are still inline data and should be blobs.""" - messages = [ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": {"url": "data:image/png,rawdata"}, - }, - ], - } - ] - converted = _convert_message_parts(messages) - blob_item = converted[0]["content"][0] - # Data URIs (with or without base64 encoding) contain inline data and should be blobs - assert blob_item["type"] == "blob" - assert blob_item["modality"] == "image" - assert blob_item["mime_type"] == "image/png" - assert blob_item["content"] == "rawdata" - - -def test_convert_message_parts_image_url_none(): - """image_url being None should not crash.""" - messages = [ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": None, - }, - ], - } - ] - converted = _convert_message_parts(messages) - # Should return item unchanged - assert converted[0]["content"][0]["type"] == "image_url" - - -def test_convert_message_parts_image_url_missing_url(): - """image_url missing the url key should not crash.""" - messages = [ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": {"detail": "high"}, - }, - ], - } - ] - converted = _convert_message_parts(messages) - # Should return item unchanged - assert converted[0]["content"][0]["type"] == "image_url"