litellm/tests/unit/text_completions/test_streaming_iterator.py
mateo-berri 97c6616945 feat(hosted_vllm): keep stop_reason and token_ids on streamed chunks and proxy stream output
Streamed chat finish chunks and streamed /v1/completions finish chunks now carry vLLM's stop_reason. Each stream chunk keeps the token_ids vLLM sent with it, including held-back empty chunks, and the proxy no longer strips those chunks as empty usage chunks or drops the fields in its fast serializer.
2026-10-28 16:41:46 -07:00

185 lines
6.7 KiB
Python

from collections.abc import AsyncIterator, Iterator
from typing import Final
import pytest
from openai.types.completion import Completion
from litellm.exceptions import APIConnectionError
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.text_completions.streaming_iterator import TextCompletionStreamWrapper
from litellm.types.utils import Choices, Delta, ModelResponseStream, StreamingChoices, Usage
def test_reasoning_content_preserved_in_text_completion_wrapper():
"""Ensure reasoning_content is copied from delta to text_choices."""
chunk = ModelResponseStream(
id="test-id",
created=1234567890,
model="test-model",
object="chat.completion.chunk",
choices=[
StreamingChoices(
finish_reason=None,
index=0,
delta=Delta(
content="Some answer text",
role="assistant",
reasoning_content="Here's my chain of thought...",
),
)
],
)
wrapper = TextCompletionStreamWrapper(
completion_stream=None, # Not used in convert_to_text_completion_object
model="test-model",
stream_options=None,
)
transformed = wrapper.convert_to_text_completion_object(chunk)
assert "choices" in transformed
assert len(transformed["choices"]) == 1
choice = transformed["choices"][0]
assert choice["text"] == "Some answer text"
assert choice["reasoning_content"] == "Here's my chain of thought..."
@pytest.mark.asyncio
@pytest.mark.parametrize("async_stream", (False, True))
@pytest.mark.parametrize("empty_choices", (False, True))
@pytest.mark.parametrize("stream_options", (None, {}, {"include_usage": False}, {"include_usage": True}))
@pytest.mark.parametrize("usage", (None, Usage(prompt_tokens=7, completion_tokens=2, total_tokens=9), Usage()))
async def test_text_completion_stream_usage_is_opt_in(
async_stream: bool, empty_choices: bool, stream_options: dict[str, bool] | None, usage: Usage | None
) -> None:
chunk: Final = ModelResponseStream(
id="test-id",
created=1234567890,
model="test-model",
choices=[] if empty_choices else [StreamingChoices(index=0, delta=Delta(content="Hello"))],
usage=usage,
)
async def chunks() -> AsyncIterator[ModelResponseStream]:
yield chunk
wrapper: Final = TextCompletionStreamWrapper(
completion_stream=chunks() if async_stream else iter((chunk,)),
model="test-model",
stream_options=stream_options,
)
responses: Final = [response async for response in wrapper] if async_stream else list(wrapper)
expected_usage: Final = (
{"usage": usage.model_dump(exclude_none=True)}
if stream_options and stream_options.get("include_usage") is True and usage is not None
else {}
)
assert [response.model_dump(exclude_none=True) for response in responses] == [
{
"id": "test-id",
"object": "text_completion",
"created": 1234567890,
"model": "test-model",
"choices": [] if empty_choices else [{"index": 0, "text": "Hello"}],
**expected_usage,
}
]
def _completion_chunk(text: str, finish_reason: str | None, **choice_extras: object) -> Completion:
return Completion.construct(
id="cmpl-1",
created=1,
model="m",
object="text_completion",
choices=[{"index": 0, "text": text, "finish_reason": finish_reason, "logprobs": None, **choice_extras}],
)
@pytest.mark.parametrize("final_text", [", 5, 6", ""], ids=["text-then-finish", "finish-only"])
def test_text_completion_stream_keeps_stop_reason_on_the_finish_chunk(final_text: str) -> None:
logging_obj: Final = Logging(
model="m",
messages=[{"role": "user", "content": "1, 2, 3, 4"}],
stream=True,
call_type="text_completion",
start_time=0.0,
litellm_call_id="call-1",
function_id="function-1",
)
upstream: Final = (_completion_chunk(", 5", None), _completion_chunk(final_text, "stop", stop_reason=", 7"))
chat_stream: Final = CustomStreamWrapper(
completion_stream=iter(upstream),
model="m",
custom_llm_provider="text-completion-openai",
logging_obj=logging_obj,
)
responses: Final = list(TextCompletionStreamWrapper(completion_stream=chat_stream, model="m"))
assert responses[-1].choices[0].model_dump(exclude_none=True) == {
"finish_reason": "stop",
"index": 0,
"stop_reason": ", 7",
}
def test_text_completion_conversion_rejects_non_streaming_choices() -> None:
chunk: Final = Choices()
wrapper: Final = TextCompletionStreamWrapper(completion_stream=None, model="test-model")
with pytest.raises(Exception, match="Error occurred converting to text completion object") as error:
wrapper.convert_to_text_completion_object(chunk)
assert str(chunk) in str(error.value)
@pytest.mark.parametrize("chunk", (None, "None"))
def test_sync_text_completion_stream_rejects_invalid_chunks(chunk: str | None) -> None:
wrapper: Final = TextCompletionStreamWrapper(
completion_stream=iter((chunk,)), model="test-model", custom_llm_provider="openai"
)
with pytest.raises(APIConnectionError, match="OpenAIException") as error:
next(wrapper)
assert (error.value.model, error.value.llm_provider) == ("test-model", "openai")
@pytest.mark.asyncio
@pytest.mark.parametrize("chunk", (None, "None"))
async def test_async_text_completion_stream_rejects_invalid_chunks(chunk: str | None) -> None:
async def chunks() -> AsyncIterator[str | None]:
yield chunk
wrapper: Final = TextCompletionStreamWrapper(completion_stream=chunks(), model="test-model")
with pytest.raises(Exception, match=r"^$") as error:
await anext(wrapper)
assert not isinstance(error.value, StopAsyncIteration)
def test_sync_text_completion_stream_maps_upstream_failure_after_content() -> None:
chunk: Final = ModelResponseStream(
id="test-id",
created=1234567890,
model="test-model",
choices=[StreamingChoices(index=0, delta=Delta(content="Hello"))],
)
def chunks() -> Iterator[ModelResponseStream]:
yield chunk
raise RuntimeError("upstream disconnected")
wrapper: Final = TextCompletionStreamWrapper(
completion_stream=chunks(), model="test-model", custom_llm_provider="openai"
)
assert next(wrapper).choices[0].text == "Hello"
with pytest.raises(APIConnectionError, match="upstream disconnected") as error:
next(wrapper)
assert (error.value.model, error.value.llm_provider) == ("test-model", "openai")