feat(hosted_vllm): keep vLLM's stop_reason and token_ids on chat choices

Non-streamed hosted_vllm chat completions moved vLLM's choice-level
stop_reason and token_ids into provider_specific_fields, so clients
written against vLLM could not find them where vLLM puts them. They are
now also set on the choice itself, and the copy in
provider_specific_fields stays for existing readers.

Fixes #47945
This commit is contained in:
Prabal Pratap Singh 2026-10-25 11:39:01 +05:30
parent 72f1ac4a27
commit 4e6d1840fe
No known key found for this signature in database
2 changed files with 112 additions and 4 deletions

View file

@ -3,8 +3,11 @@ Translate from OpenAI's `/v1/chat/completions` to VLLM's `/v1/chat/completions`
"""
import json
from collections.abc import Coroutine
from typing import Final, Literal, cast, overload
from collections.abc import Coroutine, Mapping
from typing import TYPE_CHECKING, Final, Literal, cast, overload
import httpx
from pydantic import JsonValue
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_image_mime_type_from_url,
@ -22,10 +25,24 @@ from litellm.types.llms.openai import (
ChatCompletionVideoObject,
ChatCompletionVideoUrlObject,
)
from litellm.types.utils import Choices, ModelResponse
from ....utils import remove_additional_properties, remove_strict_from_schema
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer
_VLLM_CHOICE_FIELDS: Final = ("stop_reason", "token_ids")
def _vllm_choice_fields(choice: Choices) -> Mapping[str, object]:
provider_fields: Final[Mapping[str, object]] = (
choice.provider_specific_fields or {} if "provider_specific_fields" in choice else {}
)
return {field: provider_fields[field] for field in _VLLM_CHOICE_FIELDS if field in provider_fields}
class HostedVLLMChatConfig(OpenAIGPTConfig):
def _convert_custom_tools_to_function_tools(self, tools: list[dict[str, object]]) -> list[dict[str, object]]:
@ -106,6 +123,38 @@ class HostedVLLMChatConfig(OpenAIGPTConfig):
return super().map_openai_params(non_default_params, optional_params, model, drop_params)
def transform_response(
self,
model: str,
raw_response: httpx.Response,
model_response: ModelResponse,
logging_obj: "LiteLLMLoggingObj",
request_data: dict[str, JsonValue], # mutable-ok: BaseConfig signature
messages: list[AllMessageValues], # mutable-ok: BaseConfig signature
optional_params: dict[str, JsonValue], # mutable-ok: BaseConfig signature
litellm_params: dict[str, JsonValue], # mutable-ok: BaseConfig signature
encoding: "Tokenizer | None",
api_key: str | None = None,
json_mode: bool | None = None,
) -> ModelResponse:
response: Final = super().transform_response( # pyright: ignore[reportUnknownMemberType] # OpenAIGPTConfig.transform_response takes bare dicts
model=model,
raw_response=raw_response,
model_response=model_response,
logging_obj=logging_obj,
request_data=request_data,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
encoding=encoding,
api_key=api_key,
json_mode=json_mode,
)
for choice in response.choices:
for field, value in _vllm_choice_fields(choice).items():
choice[field] = value
return response
def _get_openai_compatible_provider_info(
self, api_base: str | None, api_key: str | None
) -> tuple[str | None, str | None]:

View file

@ -1,14 +1,15 @@
import json
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import httpx
import pytest
from litellm.constants import (
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET,
)
from litellm.llms.hosted_vllm.chat.transformation import HostedVLLMChatConfig
from litellm.types.utils import ModelResponse
def test_hosted_vllm_chat_transformation_file_url():
@ -397,3 +398,61 @@ def test_hosted_vllm_custom_tools_use_top_level_input_schema():
assert tools[0]["function"]["name"] == "search"
assert tools[0]["function"]["description"] == "Search docs"
assert tools[0]["function"]["parameters"] == input_schema
def _transform_vllm_chat_response(choice_extras: dict[str, object]) -> ModelResponse:
payload = {
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 1,
"model": "glm",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "1, 2, 3, 4"},
"logprobs": None,
"finish_reason": "stop",
**choice_extras,
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
raw_response = httpx.Response(
200, json=payload, request=httpx.Request("POST", "http://vllm.local/v1/chat/completions")
)
return HostedVLLMChatConfig().transform_response(
model="hosted_vllm/glm",
raw_response=raw_response,
model_response=ModelResponse(),
logging_obj=MagicMock(),
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=None,
)
@pytest.mark.parametrize(
"choice_extras",
[
{"stop_reason": ", 5", "token_ids": [16, 11, 220]},
{"stop_reason": 128009},
{"stop_reason": None},
],
)
def test_hosted_vllm_keeps_vllm_choice_fields_on_the_choice(choice_extras: dict[str, object]):
response = _transform_vllm_chat_response(choice_extras)
dumped_choice: dict[str, dict[str, object]] = json.loads(response.model_dump_json())["choices"][0]
for field, value in choice_extras.items():
assert dumped_choice[field] == value
assert dumped_choice["provider_specific_fields"][field] == value
def test_hosted_vllm_does_not_invent_vllm_choice_fields():
response = _transform_vllm_chat_response({})
dumped_choice: dict[str, object] = json.loads(response.model_dump_json())["choices"][0]
assert "stop_reason" not in dumped_choice
assert "token_ids" not in dumped_choice