Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions tests/entrypoints/openai/test_tool_calls_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
"ValueError: Unexpected tool call id ...".
"""

import copy

import pytest

from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.pooling.embed.protocol import EmbeddingChatRequest
from vllm.entrypoints.serve.tokenize.protocol import TokenizeChatRequest


def _make_tool_call(tc_id: str, name: str, args: str) -> dict:
Expand Down Expand Up @@ -148,3 +152,65 @@ def test_multiple_tool_calls_materialised(num_tool_calls: int):
# Verify after model_dump_json too
_ = req.model_dump_json()
assert len(assistant_msg.get("tool_calls", [])) == num_tool_calls


def _tool_call_conversation() -> list:
tool_call = _make_tool_call("call_1", "lookup_weather", '{"city": "Shanghai"}')
return [
{"role": "user", "content": "What is the weather in Shanghai tomorrow?"},
{"role": "assistant", "content": None, "tool_calls": [tool_call]},
{
"role": "tool",
"tool_call_id": "call_1",
"content": '{"temp_c": 24, "condition": "cloudy"}',
},
{"role": "user", "content": "Do I need an umbrella?"},
]


@pytest.mark.parametrize(
"request_cls",
[ChatCompletionRequest, TokenizeChatRequest, EmbeddingChatRequest],
)
def test_tool_calls_materialised_for_all_chat_request_models(request_cls):
"""Every request model carrying ``messages`` must materialise tool_calls.

Regression test for https://github.com/vllm-project/vllm/issues/57730:
``/tokenize`` (and pooling chat endpoints) validated ``messages`` without
the materialisation pass used by ``/v1/chat/completions``, so an assistant
message with ``content: null`` + ``tool_calls`` kept a lazy
``ValidatorIterator`` that blew up on ``copy.deepcopy`` inside tokenizers
("cannot pickle 'ValidatorIterator' object").
"""
req = request_cls(model="test-model", messages=_tool_call_conversation())

assistant_msg = req.messages[1]
assert isinstance(assistant_msg, dict)
assert isinstance(assistant_msg.get("tool_calls"), list)

# Tokenizers/renderers deep-copy messages; this must not raise.
copied = copy.deepcopy(req.messages)
assert copied[1]["tool_calls"][0]["function"]["name"] == "lookup_weather"

# Reading twice must not consume anything.
assert len(list(assistant_msg["tool_calls"])) == 1
assert len(list(assistant_msg["tool_calls"])) == 1


@pytest.mark.parametrize(
"request_cls",
[ChatCompletionRequest, TokenizeChatRequest, EmbeddingChatRequest],
)
def test_reasoning_content_normalised_for_all_chat_request_models(request_cls):
"""Deprecated ``reasoning_content`` is renamed to ``reasoning`` everywhere."""
messages = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello", "reasoning_content": "think"},
]

req = request_cls(model="test-model", messages=messages)

assistant_msg = req.messages[1]
assert isinstance(assistant_msg, dict)
assert assistant_msg.get("reasoning") == "think"
assert "reasoning_content" not in assistant_msg
52 changes: 52 additions & 0 deletions vllm/entrypoints/chat_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,58 @@ class CustomChatCompletionMessageParam(TypedDict, total=False):
)


def normalize_chat_messages_before_validation(data: Any) -> Any:
"""Pre-process raw ``messages`` dicts before Pydantic field validation.

Intended to be called from a ``mode="before"`` model validator of any
request model that carries a ``messages: list[ChatCompletionMessageParam]``
field (chat completions, tokenize, pooling chat requests, ...).

Performs two normalizations in a single pass:

- Converts ``tool_calls`` generators/iterators to lists so one-shot
generators are not consumed during union type matching.
- Renames the deprecated ``reasoning_content`` field to ``reasoning`` so
downstream code only needs to check one field.
"""
if not isinstance(data, dict):
return data
messages = data.get("messages")
if not isinstance(messages, list):
return data
for msg in messages:
if not isinstance(msg, dict):
continue
tool_calls = msg.get("tool_calls")
if tool_calls is not None and not isinstance(tool_calls, list):
msg["tool_calls"] = list(tool_calls)
reasoning_content = msg.pop("reasoning_content", None)
if reasoning_content is not None and msg.get("reasoning") is None:
msg["reasoning"] = reasoning_content
return data


def materialize_tool_calls_in_messages(
messages: Iterable[ChatCompletionMessageParam],
) -> None:
"""Convert Pydantic ``ValidatorIterator`` wrappers back to plain lists.

Even after :func:`normalize_chat_messages_before_validation` converts
iterables to lists, Pydantic re-wraps ``tool_calls`` in a lazy
``ValidatorIterator`` when validating against the ``Iterable[...]`` type
of ``ChatCompletionAssistantMessageParam``. Such wrappers cannot be
pickled/deep-copied and are consumed on first iteration, so downstream
code (tokenizers, renderers, ``model_dump_json``) must always see plain
lists. Intended to be called from a ``mode="after"`` model validator.
"""
for msg in messages:
if not isinstance(msg, dict):
continue
tool_calls = msg.get("tool_calls")
if tool_calls is not None and not isinstance(tool_calls, list):
msg["tool_calls"] = list(tool_calls)


# TODO: Make fields ReadOnly once mypy supports it
class ConversationMessage(TypedDict, total=False):
role: Required[str]
Expand Down
41 changes: 4 additions & 37 deletions vllm/entrypoints/openai/chat_completion/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from vllm.entrypoints.chat_utils import (
ChatCompletionMessageParam,
ChatTemplateContentFormatOption,
materialize_tool_calls_in_messages,
normalize_chat_messages_before_validation,
)
from vllm.entrypoints.generate.base.protocol import (
AnyResponseFormat,
Expand Down Expand Up @@ -520,46 +522,11 @@ def check_cache_salt_support(cls, data: Any) -> Any:
@model_validator(mode="before")
@classmethod
def _normalize_messages_before(cls, data: Any) -> Any:
"""Pre-process message dicts before Pydantic field validation.

Performs two normalizations in a single pass:
- Converts tool_calls generators/iterators to lists so one-shot
generators are not consumed during union type matching.
- Renames the deprecated ``reasoning_content`` field to
``reasoning`` so downstream code only needs to check one field.
"""
if not isinstance(data, dict):
return data
messages = data.get("messages")
if not isinstance(messages, list):
return data
for msg in messages:
if not isinstance(msg, dict):
continue
tool_calls = msg.get("tool_calls")
if tool_calls is not None and not isinstance(tool_calls, list):
msg["tool_calls"] = list(tool_calls)
reasoning_content = msg.pop("reasoning_content", None)
if reasoning_content is not None and msg.get("reasoning") is None:
msg["reasoning"] = reasoning_content
return data
return normalize_chat_messages_before_validation(data)

@model_validator(mode="after")
def _materialize_tool_calls_after(self) -> "ChatCompletionRequest":
"""Convert Pydantic ValidatorIterator wrappers back to lists.

Even after the "before" validator converts iterables to lists,
Pydantic re-wraps them in a ValidatorIterator when validating
against ChatCompletionAssistantMessageParam's Iterable[...] type.
This "after" pass materialises those wrappers so downstream code
(tokenizers, model_dump_json) always sees plain lists.
"""
for msg in self.messages:
if not isinstance(msg, dict):
continue
tool_calls = msg.get("tool_calls")
if tool_calls is not None and not isinstance(tool_calls, list):
msg["tool_calls"] = list(tool_calls)
materialize_tool_calls_in_messages(self.messages)
return self

_grammar_from_parser: bool = PrivateAttr(default=False)
Expand Down
12 changes: 12 additions & 0 deletions vllm/entrypoints/pooling/base/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from vllm.entrypoints.chat_utils import (
ChatCompletionMessageParam,
ChatTemplateContentFormatOption,
materialize_tool_calls_in_messages,
normalize_chat_messages_before_validation,
)
from vllm.entrypoints.generate.base.protocol import validate_cache_salt
from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel
Expand Down Expand Up @@ -316,6 +318,16 @@ class ChatRequestMixin(ChatRequestOptionsMixin):
messages: list[ChatCompletionMessageParam]
# --8<-- [end:chat-params]

@model_validator(mode="before")
@classmethod
def _normalize_messages_before(cls, data: Any) -> Any:
return normalize_chat_messages_before_validation(data)

@model_validator(mode="after")
def _materialize_tool_calls_after(self) -> "ChatRequestMixin":
materialize_tool_calls_in_messages(self.messages)
return self


class EncodingRequestMixin(OpenAIBaseModel):
# --8<-- [start:encoding-params]
Expand Down
12 changes: 12 additions & 0 deletions vllm/entrypoints/serve/tokenize/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from vllm.entrypoints.chat_utils import (
ChatCompletionMessageParam,
ChatTemplateContentFormatOption,
materialize_tool_calls_in_messages,
normalize_chat_messages_before_validation,
)
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionToolsParam
from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel
Expand Down Expand Up @@ -125,6 +127,16 @@ def check_generation_prompt(cls, data):
)
return data

@model_validator(mode="before")
@classmethod
def _normalize_messages_before(cls, data: Any) -> Any:
return normalize_chat_messages_before_validation(data)

@model_validator(mode="after")
def _materialize_tool_calls_after(self) -> "TokenizeChatRequest":
materialize_tool_calls_in_messages(self.messages)
return self

def build_chat_params(
self,
default_template: str | None,
Expand Down
Loading