mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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.
185 lines
6.7 KiB
Python
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")
|