Skip to content

Commit 85d577c

Browse files
Fix: Handle dict objects in Anthropic streaming response (#11032)
* fix: handle dict objects in Anthropic streaming response Fix issue where dictionary objects in Anthropic streaming responses were not properly converted to SSE format strings before being yielded, causing AttributeError: 'dict' object has no attribute 'encode' * fix: refactor Anthropic streaming response handling - Added STREAM_SSE_DATA_PREFIX constant in constants.py - Created return_anthropic_chunk helper function for better maintainability - Using safe_dumps from safe_json_dumps.py for improved JSON serialization - Added unit test for dictionary object handling in streaming response * fix: correct patch path in anthropic_endpoints test
1 parent 0cde73f commit 85d577c

File tree

4 files changed

+85
-2
lines changed

4 files changed

+85
-2
lines changed

litellm/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
#### Networking settings ####
142142
request_timeout: float = float(os.getenv("REQUEST_TIMEOUT", 6000)) # time in seconds
143143
STREAM_SSE_DONE_STRING: str = "[DONE]"
144+
STREAM_SSE_DATA_PREFIX: str = "data: "
144145
### SPEND TRACKING ###
145146
DEFAULT_REPLICATE_GPU_PRICE_PER_SECOND = float(
146147
os.getenv("DEFAULT_REPLICATE_GPU_PRICE_PER_SECOND", 0.001400)

litellm/proxy/anthropic_endpoints/endpoints.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
import litellm
1414
from litellm._logging import verbose_proxy_logger
15+
from litellm.constants import STREAM_SSE_DATA_PREFIX
16+
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
1517
from litellm.proxy._types import *
1618
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
1719
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
@@ -22,6 +24,24 @@
2224
router = APIRouter()
2325

2426

27+
def return_anthropic_chunk(chunk: str | dict) -> str:
28+
"""
29+
Helper function to format streaming chunks for Anthropic API format
30+
31+
Args:
32+
chunk: A string or dictionary to be returned in SSE format
33+
34+
Returns:
35+
str: A properly formatted SSE chunk string
36+
"""
37+
if isinstance(chunk, dict):
38+
# Use safe_dumps for proper JSON serialization with circular reference detection
39+
chunk_str = safe_dumps(chunk)
40+
return f"{STREAM_SSE_DATA_PREFIX}{chunk_str}\n\n"
41+
else:
42+
return chunk
43+
44+
2545
async def async_data_generator_anthropic(
2646
response,
2747
user_api_key_dict: UserAPIKeyAuth,
@@ -40,7 +60,8 @@ async def async_data_generator_anthropic(
4060
user_api_key_dict=user_api_key_dict, response=chunk
4161
)
4262

43-
yield chunk
63+
# Format chunk using helper function
64+
yield return_anthropic_chunk(chunk)
4465
except Exception as e:
4566
verbose_proxy_logger.exception(
4667
"litellm.proxy.proxy_server.async_data_generator(): Exception occured - {}".format(
@@ -69,7 +90,7 @@ async def async_data_generator_anthropic(
6990
code=getattr(e, "status_code", 500),
7091
)
7192
error_returned = json.dumps({"error": proxy_exception.to_dict()})
72-
yield f"data: {error_returned}\n\n"
93+
yield f"{STREAM_SSE_DATA_PREFIX}{error_returned}\n\n"
7394

7495

7596
@router.post(

tests/litellm/proxy/anthropic_endpoints/__init__.py

Whitespace-only changes.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Test for anthropic_endpoints/endpoints.py, focusing on handling dictionary objects in streaming responses
3+
"""
4+
5+
import json
6+
import unittest
7+
from unittest.mock import AsyncMock, MagicMock, patch
8+
9+
import pytest
10+
11+
from litellm.proxy.anthropic_endpoints.endpoints import async_data_generator_anthropic
12+
13+
14+
class TestAnthropicEndpoints(unittest.TestCase):
15+
@patch("litellm.litellm_core_utils.safe_json_dumps.safe_dumps")
16+
@pytest.mark.asyncio
17+
async def test_async_data_generator_anthropic_dict_handling(self, mock_safe_dumps):
18+
"""Test async_data_generator_anthropic handles dictionary chunks properly"""
19+
# Setup
20+
mock_response = AsyncMock()
21+
mock_response.__aiter__.return_value = [
22+
{"type": "message_start", "message": {"id": "msg_123"}},
23+
"text chunk data",
24+
{"type": "content_block_delta", "delta": {"text": "more data"}},
25+
"text chunk data again",
26+
]
27+
28+
mock_user_api_key_dict = MagicMock()
29+
mock_request_data = {}
30+
mock_proxy_logging_obj = MagicMock()
31+
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["response"])
32+
33+
# Configure safe_dumps to return a properly formatted JSON string
34+
mock_safe_dumps.side_effect = lambda chunk: json.dumps(chunk)
35+
36+
# Execute
37+
result = [chunk async for chunk in async_data_generator_anthropic(
38+
response=mock_response,
39+
user_api_key_dict=mock_user_api_key_dict,
40+
request_data=mock_request_data,
41+
proxy_logging_obj=mock_proxy_logging_obj,
42+
)]
43+
44+
# Verify
45+
expected_result = [
46+
'data: {"type": "message_start", "message": {"id": "msg_123"}}\n\n',
47+
'text chunk data',
48+
'data: {"type": "content_block_delta", "delta": {"text": "more data"}}\n\n',
49+
'text chunk data again',
50+
]
51+
52+
self.assertEqual(result, expected_result)
53+
54+
# Assert safe_dumps was called for dictionary objects
55+
mock_safe_dumps.assert_any_call({"type": "message_start", "message": {"id": "msg_123"}})
56+
mock_safe_dumps.assert_any_call({"type": "content_block_delta", "delta": {"text": "more data"}})
57+
assert mock_safe_dumps.call_count == 2 # Called twice, once for each dict object
58+
59+
60+
if __name__ == "__main__":
61+
unittest.main()

0 commit comments

Comments
 (0)