diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index c13a976c1b9..a538223b853 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -26,6 +26,8 @@ from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI if TYPE_CHECKING: from aiohttp import ClientSession + from litellm.types.utils import ModelResponse + import litellm from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.custom_httpx.http_handler import ( @@ -288,3 +290,46 @@ def get_openai_credentials( api_key=resolved_api_key, organization=resolved_organization, ) + + +def try_parse_sse_response_body(body: Optional[str]) -> Optional["ModelResponse"]: + """Recover a ModelResponse from an SSE body returned despite stream=false. + + Some OpenAI-compatible upstreams (#25766) ignore stream=false and always + reply with SSE. Parse each `data: {...}` line and aggregate via + litellm.stream_chunk_builder. Returns None if the body isn't SSE or no + chunks could be parsed — caller falls back to the original error. + """ + from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper + + if not isinstance(body, str) or not body: + return None + + chunks: List[Dict[str, Any]] = [] + for line in body.splitlines(): + line = line.strip() + if not line: + continue + stripped = CustomStreamWrapper._strip_sse_data_from_chunk(line) + if stripped is None or stripped == line: + continue + stripped = stripped.strip() + if not stripped or stripped == "[DONE]": + continue + try: + chunks.append(json.loads(stripped)) + except json.JSONDecodeError: + continue + + if not chunks: + return None + + from litellm.types.utils import ModelResponse + + try: + result = litellm.stream_chunk_builder(chunks=chunks) + except Exception: + return None + if not isinstance(result, ModelResponse): + return None + return result diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index 194f29648c4..ad81148f38e 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -59,6 +59,7 @@ from .common_utils import ( BaseOpenAILLM, OpenAIError, drop_params_from_unprocessable_entity_error, + try_parse_sse_response_body, ) openaiOSeriesConfig = OpenAIOSeriesConfig() @@ -447,6 +448,11 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): headers = {} response = raw_response.parse() if not data.get("stream") and not hasattr(response, "model_dump"): + recovered = try_parse_sse_response_body( + getattr(raw_response, "text", None) + ) + if recovered is not None: + return headers, recovered raise OpenAIError( status_code=500, message=f"Empty or invalid response from LLM endpoint. Received: {response!r}. Check the reverse proxy or model server configuration.", @@ -485,6 +491,11 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): headers = {} response = raw_response.parse() if not data.get("stream") and not hasattr(response, "model_dump"): + recovered = try_parse_sse_response_body( + getattr(raw_response, "text", None) + ) + if recovered is not None: + return headers, recovered raise OpenAIError( status_code=500, message=f"Empty or invalid response from LLM endpoint. Received: {response!r}. Check the reverse proxy or model server configuration.", diff --git a/tests/test_litellm/llms/openai/test_openai_empty_response.py b/tests/test_litellm/llms/openai/test_openai_empty_response.py index 8a0ff237869..34caca96786 100644 --- a/tests/test_litellm/llms/openai/test_openai_empty_response.py +++ b/tests/test_litellm/llms/openai/test_openai_empty_response.py @@ -4,7 +4,7 @@ Test for issue #17209: Clearer error when LLM endpoint returns empty response import os import sys -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest @@ -126,3 +126,102 @@ class TestEmptyResponseHandling: assert response == mock_stream assert headers == {"x-request-id": "123"} + + def test_sync_sse_response_recovers(self): + """ + Issue #25766: some OpenAI-compatible upstreams ignore stream=false and + always reply with SSE. The non-streaming code path should recover by + parsing the SSE chunks and returning an aggregated ModelResponse. + """ + sse_body = ( + 'data: {"id":"chatcmpl-1","object":"chat.completion.chunk",' + '"created":1,"model":"qwen-3.6-plus","choices":[{"index":0,' + '"delta":{"role":"assistant","content":"Hello"},' + '"finish_reason":null}]}\n' + 'data: {"id":"chatcmpl-1","object":"chat.completion.chunk",' + '"created":1,"model":"qwen-3.6-plus","choices":[{"index":0,' + '"delta":{"content":" world"},"finish_reason":"stop"}]}\n' + "data: [DONE]\n" + ) + openai_chat = OpenAIChatCompletion() + + mock_raw_response = MagicMock() + mock_raw_response.headers = {} + mock_raw_response.parse.return_value = sse_body + mock_raw_response.text = sse_body + + mock_client = MagicMock() + mock_client.chat.completions.with_raw_response.create.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 hasattr(response, "model_dump") + dumped = response.model_dump() + assert dumped["choices"][0]["message"]["content"] == "Hello world" + assert dumped["choices"][0]["finish_reason"] == "stop" + + @pytest.mark.asyncio + async def test_async_sse_response_recovers(self): + """Async equivalent of test_sync_sse_response_recovers.""" + sse_body = ( + 'data: {"id":"chatcmpl-2","object":"chat.completion.chunk",' + '"created":1,"model":"qwen-3.6-plus","choices":[{"index":0,' + '"delta":{"role":"assistant","content":"hi"},' + '"finish_reason":"stop"}]}\n' + "data: [DONE]\n" + ) + openai_chat = OpenAIChatCompletion() + + mock_raw_response = MagicMock() + mock_raw_response.headers = {} + mock_raw_response.parse.return_value = sse_body + mock_raw_response.text = sse_body + + mock_client = MagicMock() + mock_client.chat.completions.with_raw_response.create = AsyncMock( + return_value=mock_raw_response + ) + + headers, response = await openai_chat.make_openai_chat_completion_request( + openai_aclient=mock_client, + data={"messages": [{"role": "user", "content": "test"}]}, + timeout=30, + logging_obj=MagicMock(), + ) + + assert hasattr(response, "model_dump") + assert response.model_dump()["choices"][0]["message"]["content"] == "hi" + + def test_sync_garbage_text_still_raises(self): + """ + Non-SSE garbage text must NOT be silently recovered — the original + "Empty or invalid response" error should still surface. + """ + openai_chat = OpenAIChatCompletion() + + mock_raw_response = MagicMock() + mock_raw_response.headers = {} + mock_raw_response.parse.return_value = "some garbage" + mock_raw_response.text = "some garbage" + + mock_client = MagicMock() + mock_client.chat.completions.with_raw_response.create.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)