Merge pull request #23497 from BerriAI/litellm_/modest-dijkstra

[Fix] BaseModelResponseIterator crashes on non-string stream chunks
This commit is contained in:
yuneng-jiang 2026-03-12 16:20:28 -07:00 • committed by GitHub
commit 976f1a0115
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 120 additions and 4 deletions

View file

@ -141,8 +141,12 @@ class BaseModelResponseIterator:
if index != -1:
str_line = str_line[index:]
# Skip empty lines (common in SSE streams between events)
if not str_line or not str_line.strip():
# Skip empty lines (common in SSE streams between events).
# Only apply to str chunks — non-string objects (e.g. Pydantic
# BaseModel events from the Responses API) must pass through.
if isinstance(str_line, str) and (
not str_line or not str_line.strip()
):
continue
# chunk is a str at this point
@ -177,8 +181,12 @@ class BaseModelResponseIterator:
if index != -1:
str_line = str_line[index:]
# Skip empty lines (common in SSE streams between events)
if not str_line or not str_line.strip():
# Skip empty lines (common in SSE streams between events).
# Only apply to str chunks — non-string objects (e.g. Pydantic
# BaseModel events from the Responses API) must pass through.
if isinstance(str_line, str) and (
not str_line or not str_line.strip()
):
continue
# chunk is a str at this point

View file

@ -1,8 +1,11 @@
"""
Tests for BaseModelResponseIterator - specifically testing that empty SSE lines are filtered
and non-string objects (e.g. Pydantic BaseModel events from the Responses API) pass through.
"""
import pytest
from pydantic import BaseModel
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.types.utils import GenericStreamingChunk
@ -115,3 +118,108 @@ async def test_filter_empty_sse_lines_async():
# Should have 3 chunks: 2 content + 1 DONE
assert len(chunks) == 3, f"Expected 3 chunks, got {len(chunks)}"
class FakeResponseEvent(BaseModel):
"""Simulates a Pydantic BaseModel event like ResponseCreatedEvent from the OpenAI SDK."""
type: str = "response.created"
data: dict = {}
class TestBaseModelResponseIteratorNonStringChunks:
"""
Test that non-string objects (e.g. Pydantic BaseModel events from the
Responses API) are not dropped by the empty-line filter.
Without the isinstance(str_line, str) guard, calling .strip() on a
BaseModel raises AttributeError: 'FakeResponseEvent' object has no
attribute 'strip'.
"""
def test_pydantic_basemodel_chunk_passes_through_sync(self):
"""Non-string chunks must not be dropped or cause AttributeError."""
event = FakeResponseEvent(type="response.created", data={"id": "resp_1"})
class TestIterator(BaseModelResponseIterator):
def _handle_string_chunk(self, str_line):
# Just return the object wrapped in a GenericStreamingChunk
return GenericStreamingChunk(
text=str(str_line),
is_finished=False,
finish_reason="",
usage=None,
index=0,
tool_use=None,
)
iterator = TestIterator(
streaming_response=iter([event]),
sync_stream=True,
)
chunks = list(iterator)
assert len(chunks) == 1
assert "response.created" in chunks[0]["text"]
def test_mixed_string_and_pydantic_chunks_sync(self):
"""Mix of empty strings, valid SSE, and Pydantic objects."""
event = FakeResponseEvent(type="response.done", data={})
class TestIterator(BaseModelResponseIterator):
def _handle_string_chunk(self, str_line):
return GenericStreamingChunk(
text=str(str_line),
is_finished=False,
finish_reason="",
usage=None,
index=0,
tool_use=None,
)
items = [
"", # empty string — should be skipped
event, # Pydantic object — must pass through
" ", # whitespace — should be skipped
"data: [DONE]", # valid SSE
]
iterator = TestIterator(
streaming_response=iter(items),
sync_stream=True,
)
chunks = list(iterator)
# 2 chunks: the Pydantic event + [DONE]
assert len(chunks) == 2
@pytest.mark.asyncio
async def test_pydantic_basemodel_chunk_passes_through_async():
"""Async variant: non-string chunks must not be dropped."""
event = FakeResponseEvent(type="response.created", data={"id": "resp_1"})
class TestIterator(BaseModelResponseIterator):
def _handle_string_chunk(self, str_line):
return GenericStreamingChunk(
text=str(str_line),
is_finished=False,
finish_reason="",
usage=None,
index=0,
tool_use=None,
)
async def async_gen():
yield event
iterator = TestIterator(
streaming_response=async_gen(),
sync_stream=False,
)
chunks = []
async for chunk in iterator:
chunks.append(chunk)
assert len(chunks) == 1
assert "response.created" in chunks[0]["text"]