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.
This commit is contained in:
mateo-berri 2026-10-28 16:41:46 -07:00
parent 40a5a57d0a
commit 97c6616945
8 changed files with 188 additions and 4 deletions

View file

@ -685,6 +685,8 @@ STREAM_SSE_DONE_STRING: Final[str] = "[DONE]"
STREAM_SSE_DATA_PREFIX: Final[str] = "data: "
STREAM_SSE_KEEPALIVE_PING_CHUNK: Final[str] = 'event: ping\ndata: {"type": "ping"}\n\n'
STREAM_SSE_KEEPALIVE_PING_BYTES: Final[bytes] = STREAM_SSE_KEEPALIVE_PING_CHUNK.encode("utf-8")
STREAM_FINISH_CHOICE_FIELDS: Final = ("stop_reason",)
STREAM_CHUNK_CHOICE_FIELDS: Final = ("token_ids",)
### SPEND TRACKING ###
DEFAULT_REPLICATE_GPU_PRICE_PER_SECOND: Final = get_env_float(
"DEFAULT_REPLICATE_GPU_PRICE_PER_SECOND", 0.001400

View file

@ -23,6 +23,7 @@ from typing_extensions import NotRequired, TypedDict
import litellm
from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.constants import STREAM_CHUNK_CHOICE_FIELDS, STREAM_FINISH_CHOICE_FIELDS
from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.core_helpers import chunk_usage, provider_reported_usage_cost
from litellm.litellm_core_utils.hidden_params import (
@ -113,6 +114,29 @@ def _prepend_upstream_choice_extras(
choice.__pydantic_fields_set__.update(upstream_extras)
_NO_UPSTREAM_CHOICE_FIELDS: Final[Mapping[str, object]] = MappingProxyType({})
def _upstream_choice_fields(upstream_chunk: object, fields: Sequence[str]) -> Mapping[str, object]:
match getattr(upstream_chunk, "choices", None):
case [BaseModel() as first_choice, *_]:
upstream_extras: Final[Mapping[str, object]] = first_choice.model_extra or {}
return MappingProxyType({field: upstream_extras[field] for field in fields if field in upstream_extras})
case _:
return _NO_UPSTREAM_CHOICE_FIELDS
def _non_null_upstream_choice_fields(upstream_chunk: object, fields: Sequence[str]) -> Mapping[str, object]:
return MappingProxyType(
{field: value for field, value in _upstream_choice_fields(upstream_chunk, fields).items() if value is not None}
)
def _apply_choice_fields(choice: StreamingChoices, fields: Mapping[str, object]) -> None:
for field, value in fields.items():
setattr(choice, field, value)
def _holds_only_scalars(fields: Iterable[tuple[str, object]]) -> bool:
return all(value is None or isinstance(value, (str, int, float, bool)) for _, value in fields)
@ -337,6 +361,7 @@ class CustomStreamWrapper:
self.system_fingerprint: str | None = None
self._provider_response_model: str | None = None
self.received_finish_reason: str | None = None
self.received_finish_choice_fields: Mapping[str, object] = _NO_UPSTREAM_CHOICE_FIELDS
self.intermittent_finish_reason: str | None = None # finish reasons that show up mid-stream
self.special_tokens = [
"<|assistant|>",
@ -1268,12 +1293,24 @@ class CustomStreamWrapper:
model_response.choices[0].finish_reason = map_finish_reason(
finish_reason=self.received_finish_reason
) # ensure consistent output to openai
_apply_choice_fields(model_response.choices[0], self.received_finish_choice_fields)
if response_obj.get("is_finished"):
_apply_choice_fields(
model_response.choices[0],
_non_null_upstream_choice_fields(_original_chunk, STREAM_CHUNK_CHOICE_FIELDS),
)
self.sent_last_chunk = True
return model_response
elif self._has_special_delta_content(model_response):
return self._handle_special_delta_content(model_response)
elif upstream_chunk_fields := _non_null_upstream_choice_fields(
response_obj.get("original_chunk"), STREAM_CHUNK_CHOICE_FIELDS
):
model_response.choices[0].delta = Delta(content=None) # rebind-ok: chunks are built in place here
_apply_choice_fields(model_response.choices[0], upstream_chunk_fields)
return model_response
else:
if hasattr(model_response, "usage"):
self.chunks.append(model_response)
@ -1492,6 +1529,7 @@ class CustomStreamWrapper:
print_verbose(f"completion obj content: {completion_obj['content']}")
if response_obj["is_finished"]:
self.received_finish_reason = response_obj["finish_reason"]
self.received_finish_choice_fields = _upstream_choice_fields(chunk, STREAM_FINISH_CHOICE_FIELDS)
if response_obj["usage"] is not None:
_text_completion_usage: Final[Usage] = response_obj["usage"]
setattr(
@ -1575,6 +1613,7 @@ class CustomStreamWrapper:
f"{self.custom_llm_provider} raised a streaming error - finish_reason: error, no content string given. Received Chunk={response_obj}"
)
self.received_finish_reason = response_obj["finish_reason"]
self.received_finish_choice_fields = _upstream_choice_fields(chunk, STREAM_FINISH_CHOICE_FIELDS)
if response_obj.get("original_chunk", None) is not None:
if hasattr(response_obj["original_chunk"], "id"):
model_response = self.set_model_id(response_obj["original_chunk"].id, model_response)
@ -1920,6 +1959,7 @@ class CustomStreamWrapper:
model_response.choices[0].finish_reason = _finish_reason
else:
model_response.choices[0].finish_reason = "stop"
_apply_choice_fields(model_response.choices[0], self.received_finish_choice_fields)
## if tool use
if (

View file

@ -10061,6 +10061,9 @@ def _restamp_nested_response_model_in_sse_line(line: bytes, target_model: str) -
return _SSE_DATA_FIELD + b" " + serialized_event.encode() + line_ending
_STANDARD_STREAM_CHOICE_FIELDS: Final = frozenset({"finish_reason", "index", "delta", "logprobs", "enhancements"})
def _fast_serialize_simple_model_response_stream(
chunk: ModelResponseStream,
) -> bytes | None:
@ -10083,6 +10086,8 @@ def _fast_serialize_simple_model_response_stream(
choice: Final = choices[0]
if getattr(choice, "logprobs", None) is not None or getattr(choice, "enhancements", None) is not None:
return None
if not isinstance(choice, BaseModel) or (choice.model_extra or {}).keys() - _STANDARD_STREAM_CHOICE_FIELDS:
return None
delta: Final[object] = getattr(choice, "delta", None)
if delta is None:
@ -10161,6 +10166,11 @@ def _is_empty_streaming_choice(choice: StreamingChoices) -> bool:
return False
if getattr(choice, "logprobs", None) is not None:
return False
provider_choice_values: Final = (
value for field, value in (choice.model_extra or {}).items() if field not in _STANDARD_STREAM_CHOICE_FIELDS
)
if not _all_values_none(provider_choice_values):
return False
delta: Final = choice.delta if hasattr(choice, "delta") else None
if delta is None:
return True

View file

@ -1,5 +1,6 @@
from typing import Final
from litellm.constants import STREAM_FINISH_CHOICE_FIELDS
from litellm.types.utils import Choices, ModelResponse, TextChoices, TextCompletionResponse
@ -34,11 +35,15 @@ class TextCompletionStreamWrapper:
raise Exception
if chunk["choices"]:
delta: Final = chunk["choices"][0]["delta"]
chat_choice: Final = chunk["choices"][0]
delta: Final = chat_choice["delta"]
text_choices["text"] = delta["content"]
text_choices["reasoning_content"] = delta.get("reasoning_content")
text_choices["index"] = chunk["choices"][0]["index"]
text_choices["finish_reason"] = chunk["choices"][0]["finish_reason"]
text_choices["index"] = chat_choice["index"]
text_choices["finish_reason"] = chat_choice["finish_reason"]
for field in STREAM_FINISH_CHOICE_FIELDS:
if field in chat_choice:
text_choices[field] = chat_choice[field]
response["choices"] = [text_choices]
else:
response["choices"] = []

View file

@ -7,6 +7,7 @@ import threading
import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from itertools import chain
from types import CodeType, FrameType, MappingProxyType
from typing import AsyncIterator, Callable, Final, Iterator, Literal, NoReturn, Optional, cast
from unittest.mock import AsyncMock, MagicMock, Mock, patch
@ -28,6 +29,7 @@ from litellm.litellm_core_utils.streaming_handler import (
_ProviderChunkParsed,
)
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.hosted_vllm.chat.transformation import HostedVLLMChatConfig
from litellm.responses.mcp.chat_completions_handler import _add_mcp_metadata_to_response
from litellm.types.utils import (
CompletionTokensDetailsWrapper,
@ -5901,6 +5903,70 @@ def test_chunk_creator_keeps_upstream_index_on_single_choice_chunk(logging_obj:
assert result.choices[0].delta.content == "hello"
def _hosted_vllm_stream_choices(
logging_obj: Logging, upstream_choices: Sequence[Mapping[str, object]]
) -> tuple[dict[str, object], ...]:
sse_lines: Final = (
*(
"data: " + json.dumps({"id": "chatcmpl-1", "object": "chat.completion.chunk", "model": "m", "choices": [c]})
for c in upstream_choices
),
"data: [DONE]",
)
completion_stream: Final = HostedVLLMChatConfig().get_model_response_iterator(
streaming_response=iter(sse_lines), sync_stream=True, json_mode=False
)
return tuple(
chunk.choices[0].model_dump()
for chunk in CustomStreamWrapper(
completion_stream=completion_stream, model="m", custom_llm_provider="hosted_vllm", logging_obj=logging_obj
)
)
@pytest.mark.parametrize("stop_reason", [",5", 151645])
@pytest.mark.parametrize("final_content", [",3", ""], ids=["content-then-finish", "finish-only"])
def test_hosted_vllm_stream_keeps_stop_reason_and_every_token_id(
logging_obj: Logging, final_content: str, stop_reason: str | int
) -> None:
emitted: Final = _hosted_vllm_stream_choices(
logging_obj,
(
{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None},
{"index": 0, "delta": {"content": ""}, "finish_reason": None, "token_ids": [16]},
{"index": 0, "delta": {"content": "1,2"}, "finish_reason": None, "token_ids": [11, 17]},
{
"index": 0,
"delta": {"content": final_content},
"finish_reason": "stop",
"stop_reason": stop_reason,
"token_ids": [20],
},
),
)
assert (emitted[-1]["finish_reason"], emitted[-1].get("stop_reason")) == ("stop", stop_reason)
assert list(chain.from_iterable(choice.get("token_ids") or () for choice in emitted)) == [16, 11, 17, 20]
def _held_back_stop_string_stream(token_ids: Mapping[str, None]) -> tuple[Mapping[str, object], ...]:
return (
{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None},
{"index": 0, "delta": {"content": ""}, "finish_reason": None, **token_ids},
{"index": 0, "delta": {"content": "1,2"}, "finish_reason": None, **token_ids},
{"index": 0, "delta": {"content": ""}, "finish_reason": "stop", "stop_reason": ",3", **token_ids},
)
def test_hosted_vllm_stream_with_null_token_ids_emits_the_same_chunks_as_without_them(logging_obj: Logging) -> None:
with_null_ids: Final = _hosted_vllm_stream_choices(logging_obj, _held_back_stop_string_stream({"token_ids": None}))
without_ids: Final = _hosted_vllm_stream_choices(logging_obj, _held_back_stop_string_stream({}))
assert [(c["delta"], c["finish_reason"]) for c in with_null_ids] == [
(c["delta"], c["finish_reason"]) for c in without_ids
]
def _later_chunk_outputs(
custom_llm_provider: str, later_chunks: Sequence[ChatCompletionChunk], logging_obj: Logging
) -> tuple[ModelResponseStream, ...]:

View file

@ -576,6 +576,16 @@ def test_serialize_streaming_chunk_simple_uses_fast_path_bytes():
}
def test_serialize_streaming_chunk_keeps_provider_choice_fields():
chunk = _simple_chunk(content="1,2,3,4")
chunk.choices[0]["stop_reason"] = ",5"
chunk.choices[0]["token_ids"] = [20]
assert json.loads(_serialize_streaming_chunk(chunk))["choices"] == [
{"index": 0, "delta": {"role": "assistant", "content": "1,2,3,4"}, "stop_reason": ",5", "token_ids": [20]}
]
def test_serialize_streaming_chunk_invalid_input_raises_attribute_error():
"""The helper is typed as ``BaseModel`` — handing it a plain dict trips
the attribute-access path (no ``model_dump_json``)."""

View file

@ -13582,7 +13582,7 @@ def _stream_usage_generator_chunks():
def test_is_injected_stream_usage_artifact():
from litellm.proxy.proxy_server import _is_injected_stream_usage_artifact
from litellm.types.utils import ModelResponseStream, Usage
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices, Usage
content_chunk, finish_chunk, empty_choices_usage_chunk = _stream_usage_test_chunks()
assert _is_injected_stream_usage_artifact(empty_choices_usage_chunk) is True
@ -13605,6 +13605,16 @@ def test_is_injected_stream_usage_artifact():
assert _is_injected_stream_usage_artifact({"usage": {"prompt_tokens": 1}}) is False
held_back_token_ids_chunk = ModelResponseStream(
model="qwen3", choices=[StreamingChoices(delta=Delta(content=None), token_ids=[16])]
)
assert _is_injected_stream_usage_artifact(held_back_token_ids_chunk) is False
null_token_ids_chunk = ModelResponseStream(
model="qwen3", choices=[StreamingChoices(delta=Delta(content=None), token_ids=None)]
)
assert _is_injected_stream_usage_artifact(null_token_ids_chunk) is True
async def _collect_async_data_generator_frames(request_data: dict) -> list:
from litellm.proxy.proxy_server import async_data_generator

View file

@ -2,8 +2,11 @@ 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
@ -85,6 +88,44 @@ async def test_text_completion_stream_usage_is_opt_in(
]
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")