mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
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:
parent
c94a8d6514
commit
423bf2d2c9
3 changed files with 156 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue