fix(openai): recover from SSE response when stream=false (#25766)

Some OpenAI-compatible upstreams (e.g. reverse-proxies) ignore the
stream=false flag and always reply in SSE format. Add
try_parse_sse_response_body() to common_utils.py that detects this
case, parses the data: chunks, and aggregates them into a ModelResponse
via the existing stream_chunk_builder. Wire it into both the sync and
async make_*_openai_chat_completion_request methods as a recovery path
before raising the "Empty or invalid response" error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ShirGanon 2026-05-02 13:53:35 +03:00
parent c94a8d6514
commit 423bf2d2c9
3 changed files with 156 additions and 1 deletions

View file

@ -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

View file

@ -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.",

View file

@ -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)