mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
* fix(openai): send chat completions through client.post so the SDK's per-message param transform stops holding the event loop create() walks every message, content part and tool of the request through openai._utils._transform before posting, 91 ms at 300k tokens and ~300 ms at 1M tokens on the event loop, and returns the same dict for the plain dicts litellm builds. client.post is what create() calls after that walk, same request, headers, retries, raw response and stream objects, so the chat helpers now call it directly through the helper embeddings already used * fix(openai): type the chat client.post result as LegacyAPIResponse so the type gate sees .headers and .parse * fix(openai): assert the chat client.post result is a LegacyAPIResponse instead of casting it * test(openai): stub client.post with a LegacyAPIResponse spec in the empty-response and no-leak tests * test(core-utils): stub client.post with a LegacyAPIResponse spec in the agentic loop leak test * test: every client.post stub on the OpenAI chat path returns a LegacyAPIResponse-spec'd mock --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
125 lines
4.6 KiB
Python
125 lines
4.6 KiB
Python
"""
|
|
Test for issue #17209: Clearer error when LLM endpoint returns empty response
|
|
"""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from openai._legacy_response import LegacyAPIResponse
|
|
|
|
from litellm.llms.openai.common_utils import OpenAIError
|
|
from litellm.llms.openai.openai import OpenAIChatCompletion
|
|
|
|
|
|
class TestEmptyResponseHandling:
|
|
"""Test that empty/invalid responses from LLM endpoints produce clear error messages"""
|
|
|
|
def test_sync_empty_string_response_raises_clear_error(self):
|
|
"""
|
|
Test that when an OpenAI-compatible endpoint returns an empty string,
|
|
we get a clear error instead of "'str' object has no attribute 'model_dump'"
|
|
"""
|
|
openai_chat = OpenAIChatCompletion()
|
|
|
|
# Mock the raw response to return an empty string from parse()
|
|
mock_raw_response = MagicMock(spec=LegacyAPIResponse)
|
|
mock_raw_response.headers = {}
|
|
mock_raw_response.parse.return_value = "" # Empty string response
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.post.return_value = (
|
|
mock_raw_response
|
|
)
|
|
|
|
with pytest.raises(OpenAIError) as exc_info:
|
|
openai_chat.make_sync_openai_chat_completion_request(
|
|
openai_client=mock_client,
|
|
data={"messages": [{"role": "user", "content": "test"}]},
|
|
timeout=30,
|
|
logging_obj=MagicMock(),
|
|
)
|
|
|
|
assert "Empty or invalid response from LLM endpoint" in str(exc_info.value)
|
|
assert "Check the reverse proxy or model server configuration" in str(
|
|
exc_info.value
|
|
)
|
|
|
|
def test_sync_none_response_raises_clear_error(self):
|
|
"""Test that None response also produces a clear error"""
|
|
openai_chat = OpenAIChatCompletion()
|
|
|
|
mock_raw_response = MagicMock(spec=LegacyAPIResponse)
|
|
mock_raw_response.headers = {}
|
|
mock_raw_response.parse.return_value = None
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.post.return_value = (
|
|
mock_raw_response
|
|
)
|
|
|
|
with pytest.raises(OpenAIError) as exc_info:
|
|
openai_chat.make_sync_openai_chat_completion_request(
|
|
openai_client=mock_client,
|
|
data={"messages": [{"role": "user", "content": "test"}]},
|
|
timeout=30,
|
|
logging_obj=MagicMock(),
|
|
)
|
|
|
|
assert "Empty or invalid response from LLM endpoint" in str(exc_info.value)
|
|
|
|
def test_valid_response_passes_through(self):
|
|
"""Test that a valid response with model_dump passes through correctly"""
|
|
openai_chat = OpenAIChatCompletion()
|
|
|
|
# Create a mock response that has model_dump (like a real Pydantic model)
|
|
mock_response = MagicMock()
|
|
mock_response.model_dump.return_value = {"choices": []}
|
|
|
|
mock_raw_response = MagicMock(spec=LegacyAPIResponse)
|
|
mock_raw_response.headers = {"x-request-id": "123"}
|
|
mock_raw_response.parse.return_value = mock_response
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.post.return_value = (
|
|
mock_raw_response
|
|
)
|
|
|
|
headers, response = openai_chat.make_sync_openai_chat_completion_request(
|
|
openai_client=mock_client,
|
|
data={"messages": [{"role": "user", "content": "test"}]},
|
|
timeout=30,
|
|
logging_obj=MagicMock(),
|
|
)
|
|
|
|
assert response == mock_response
|
|
assert headers == {"x-request-id": "123"}
|
|
|
|
def test_sync_streaming_response_passes_through_without_model_dump(self):
|
|
"""
|
|
Test that streaming responses (which don't have model_dump) pass through
|
|
correctly without raising an error. This validates the fix for VLLM streaming.
|
|
"""
|
|
openai_chat = OpenAIChatCompletion()
|
|
|
|
# Create a mock response WITHOUT model_dump (like an AsyncStream/Iterator)
|
|
mock_stream = MagicMock(spec=[]) # spec=[] means no attributes
|
|
|
|
mock_raw_response = MagicMock(spec=LegacyAPIResponse)
|
|
mock_raw_response.headers = {"x-request-id": "123"}
|
|
mock_raw_response.parse.return_value = mock_stream
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.post.return_value = (
|
|
mock_raw_response
|
|
)
|
|
|
|
# Key: data has stream=True - this should bypass the model_dump check
|
|
headers, response = openai_chat.make_sync_openai_chat_completion_request(
|
|
openai_client=mock_client,
|
|
data={"messages": [{"role": "user", "content": "test"}], "stream": True},
|
|
timeout=30,
|
|
logging_obj=MagicMock(),
|
|
)
|
|
|
|
assert response == mock_stream
|
|
assert headers == {"x-request-id": "123"}
|