mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
fix(custom_llm): allow streaming/astreaming to yield ModelResponseStream directly
This commit is contained in:
parent
9380940ced
commit
edf75d6783
3 changed files with 194 additions and 1 deletions
|
|
@ -1128,6 +1128,21 @@ class CustomStreamWrapper:
|
|||
completion_obj: Dict[str, Any] = {"content": ""}
|
||||
from litellm.types.utils import GenericStreamingChunk as GChunk
|
||||
|
||||
if isinstance(chunk, ModelResponseStream):
|
||||
_has_content = bool(
|
||||
chunk.choices
|
||||
and chunk.choices[0].delta is not None
|
||||
and chunk.choices[0].delta.content
|
||||
)
|
||||
if self.received_finish_reason is not None:
|
||||
if not _has_content:
|
||||
raise StopIteration
|
||||
if chunk.choices and chunk.choices[0].finish_reason:
|
||||
self.received_finish_reason = chunk.choices[0].finish_reason
|
||||
if not _has_content:
|
||||
return None
|
||||
return chunk
|
||||
|
||||
if (
|
||||
isinstance(chunk, dict)
|
||||
and generic_chunk_has_all_required_fields(
|
||||
|
|
|
|||
|
|
@ -44,7 +44,14 @@ from litellm import (
|
|||
image_generation,
|
||||
)
|
||||
from litellm.utils import ModelResponseIterator
|
||||
from litellm.types.utils import ImageResponse, ImageObject, EmbeddingResponse
|
||||
from litellm.types.utils import (
|
||||
ImageResponse,
|
||||
ImageObject,
|
||||
EmbeddingResponse,
|
||||
ModelResponseStream,
|
||||
StreamingChoices,
|
||||
Delta,
|
||||
)
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
|
||||
|
||||
|
|
@ -644,3 +651,82 @@ async def test_simple_aembedding():
|
|||
"embedding": [0.1, 0.2, 0.3],
|
||||
"index": 1,
|
||||
}
|
||||
|
||||
|
||||
# ── Tests for ModelResponseStream passthrough in custom providers (issue #27389) ──
|
||||
|
||||
|
||||
class ModelResponseStreamLLM(MyCustomLLM):
|
||||
"""Subclass that overrides streaming/astreaming to yield ModelResponseStream directly."""
|
||||
|
||||
def __init__(self, finish_reason: str = "stop"):
|
||||
self._finish_reason = finish_reason
|
||||
|
||||
def streaming(self, *args, **kwargs) -> Iterator[ModelResponseStream]: # type: ignore
|
||||
yield ModelResponseStream(
|
||||
id="test-stream-id",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content="Hello world"),
|
||||
finish_reason=self._finish_reason,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def astreaming(self, *args, **kwargs) -> AsyncIterator[ModelResponseStream]: # type: ignore
|
||||
yield ModelResponseStream(
|
||||
id="test-stream-id",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content="Hello world"),
|
||||
finish_reason=self._finish_reason,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"finish_reason", ["stop", "tool_calls", "length", "content_filter"]
|
||||
)
|
||||
def test_custom_llm_streaming_model_response_stream(finish_reason):
|
||||
my_custom_llm = ModelResponseStreamLLM(finish_reason=finish_reason)
|
||||
litellm.custom_provider_map = [
|
||||
{"provider": "custom_llm", "custom_handler": my_custom_llm}
|
||||
]
|
||||
resp = completion(
|
||||
model="custom_llm/my-fake-model",
|
||||
messages=[{"role": "user", "content": "Hello world!"}],
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for chunk in resp:
|
||||
print(chunk)
|
||||
if chunk.choices[0].finish_reason is None:
|
||||
assert isinstance(chunk.choices[0].delta.content, str)
|
||||
else:
|
||||
assert chunk.choices[0].finish_reason == finish_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"finish_reason", ["stop", "tool_calls", "length", "content_filter"]
|
||||
)
|
||||
async def test_custom_llm_astreaming_model_response_stream(finish_reason):
|
||||
my_custom_llm = ModelResponseStreamLLM(finish_reason=finish_reason)
|
||||
litellm.custom_provider_map = [
|
||||
{"provider": "custom_llm", "custom_handler": my_custom_llm}
|
||||
]
|
||||
resp = await litellm.acompletion(
|
||||
model="custom_llm/my-fake-model",
|
||||
messages=[{"role": "user", "content": "Hello world!"}],
|
||||
stream=True,
|
||||
)
|
||||
|
||||
async for chunk in resp:
|
||||
print(chunk)
|
||||
if chunk.choices[0].finish_reason is None:
|
||||
assert isinstance(chunk.choices[0].delta.content, str)
|
||||
else:
|
||||
assert chunk.choices[0].finish_reason == finish_reason
|
||||
|
|
|
|||
|
|
@ -2124,3 +2124,95 @@ def test_gemini_legacy_vertex_tool_calls_finish_reason_with_stop_enum():
|
|||
f"Expected 'tool_calls' but got {final.choices[0].finish_reason!r}. "
|
||||
"STOP enum was not normalised through map_finish_reason()."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"finish_reason", ["stop", "tool_calls", "length", "content_filter"]
|
||||
)
|
||||
def test_chunk_creator_passes_through_model_response_stream(
|
||||
initialized_custom_stream_wrapper: CustomStreamWrapper,
|
||||
finish_reason: str,
|
||||
):
|
||||
"""
|
||||
chunk_creator must pass ModelResponseStream chunks from custom providers
|
||||
straight through and preserve finish_reason exactly — not force-cast to GChunk.
|
||||
Regression test for issue #27389.
|
||||
"""
|
||||
initialized_custom_stream_wrapper.custom_llm_provider = "my-custom-provider"
|
||||
litellm._custom_providers.append("my-custom-provider")
|
||||
|
||||
chunk = ModelResponseStream(
|
||||
id="test-id",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content="Hello", role="assistant"),
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = initialized_custom_stream_wrapper.chunk_creator(chunk=chunk)
|
||||
|
||||
litellm._custom_providers.remove("my-custom-provider")
|
||||
|
||||
assert result is not None
|
||||
assert initialized_custom_stream_wrapper.received_finish_reason == finish_reason
|
||||
|
||||
|
||||
def test_chunk_creator_drops_empty_finish_chunk(
|
||||
initialized_custom_stream_wrapper: CustomStreamWrapper,
|
||||
):
|
||||
"""
|
||||
A ModelResponseStream chunk with finish_reason but no content should return
|
||||
None so finish_reason_handler() synthesises the final chunk — mirrors GChunk
|
||||
behaviour via is_chunk_non_empty.
|
||||
"""
|
||||
initialized_custom_stream_wrapper.custom_llm_provider = "my-custom-provider"
|
||||
litellm._custom_providers.append("my-custom-provider")
|
||||
|
||||
chunk = ModelResponseStream(
|
||||
id="test-id",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content=""),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = initialized_custom_stream_wrapper.chunk_creator(chunk=chunk)
|
||||
|
||||
litellm._custom_providers.remove("my-custom-provider")
|
||||
|
||||
assert result is None
|
||||
assert initialized_custom_stream_wrapper.received_finish_reason == "stop"
|
||||
|
||||
|
||||
def test_chunk_creator_stops_iteration_on_trailing_chunk(
|
||||
initialized_custom_stream_wrapper: CustomStreamWrapper,
|
||||
):
|
||||
"""
|
||||
After received_finish_reason is set, any empty trailing chunk (e.g. provider
|
||||
metadata flush) must raise StopIteration to end the stream cleanly.
|
||||
"""
|
||||
initialized_custom_stream_wrapper.custom_llm_provider = "my-custom-provider"
|
||||
initialized_custom_stream_wrapper.received_finish_reason = "stop"
|
||||
litellm._custom_providers.append("my-custom-provider")
|
||||
|
||||
trailing_chunk = ModelResponseStream(
|
||||
id="test-id",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content=None),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
initialized_custom_stream_wrapper.chunk_creator(chunk=trailing_chunk)
|
||||
|
||||
litellm._custom_providers.remove("my-custom-provider")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue