Skip to content
Merged
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
14 changes: 4 additions & 10 deletions tests/entrypoints/openai/test_tool_choice_content_none.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import pytest

from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.engine.serving import OpenAIServing
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.parser.abstract_parser import DelegatingParser

Expand Down Expand Up @@ -32,11 +31,8 @@ def extract_reasoning_streaming(
):
return None

def extract_tool_calls(self, model_output: str, request):
return None


def test_parse_tool_calls_from_content_allows_named_tool_choice_with_none_content():
def test_chat_completion_named_tool_choice_with_none_content():
request = ChatCompletionRequest.model_validate(
{
"model": "test-model",
Expand All @@ -53,17 +49,15 @@ def test_parse_tool_calls_from_content_allows_named_tool_choice_with_none_conten
"tool_choice": {"type": "function", "function": {"name": "get_weather"}},
}
)
parser = _DummyDelegatingParser(tokenizer=None)

tool_calls, content = OpenAIServing._parse_tool_calls_from_content(
tool_calls, content = parser._extract_tool_calls(
content=None,
request=request,
tokenizer=None,
enable_auto_tools=True,
tool_parser_cls=None,
content=None,
)

assert content is None
assert tool_calls is not None
assert tool_calls == []


Expand Down
262 changes: 262 additions & 0 deletions tests/parser/test_parse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project

import json

import pytest

from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.parser.abstract_parser import _WrappedParser
from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser


class ThinkReasoningParser(BaseThinkingReasoningParser):
@property
def start_token(self) -> str:
return "<think>"

@property
def end_token(self) -> str:
return "</think>"


MODEL_OUTPUT = (
"<think>let me think about this</think>"
'<tool_call>\n{"name": "get_weather", '
'"arguments": {"city": "Dallas"}}\n</tool_call>'
)

PLAIN_TEXT = "The weather in Dallas is sunny and 75°F."

TOOL_CALL_ONLY = (
'<tool_call>\n{"name": "get_weather", '
'"arguments": {"city": "Dallas"}}\n</tool_call>'
)

TOOL_ARGUMENTS = '{"city": "Dallas"}'


@pytest.fixture(scope="module")
def tokenizer():
from vllm.tokenizers import get_tokenizer

return get_tokenizer("Qwen/Qwen3-32B")


def make_request(**overrides):
base = {
"model": "test-model",
"messages": [{"role": "user", "content": "hi"}],
}
base.update(overrides)
return ChatCompletionRequest.model_validate(base)


TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {"type": "object", "properties": {}},
},
}
]


def make_parser(tokenizer, reasoning=False, tool=False):
_WrappedParser.reasoning_parser_cls = ThinkReasoningParser if reasoning else None
_WrappedParser.tool_parser_cls = Hermes2ProToolParser if tool else None
return _WrappedParser(tokenizer)


@pytest.mark.parametrize(
"reasoning,tool",
[(False, False), (False, True)],
ids=["neither", "tool-only"],
)
def test_parse_plain_text_no_reasoning_parser(tokenizer, reasoning, tool):
parser = make_parser(tokenizer, reasoning=reasoning, tool=tool)
request = make_request()
r, content, tool_calls = parser.parse(PLAIN_TEXT, request)

assert r is None
assert content == PLAIN_TEXT
assert tool_calls is not None
assert len(tool_calls) == 0


@pytest.mark.parametrize(
"reasoning,tool",
[(True, False), (True, True)],
ids=["reasoning-only", "both"],
)
def test_parse_plain_text_with_reasoning_parser(tokenizer, reasoning, tool):
parser = make_parser(tokenizer, reasoning=reasoning, tool=tool)
request = make_request()
r, content, tool_calls = parser.parse(PLAIN_TEXT, request)

assert r == PLAIN_TEXT
assert content is None
assert tool_calls is not None
assert len(tool_calls) == 0


def test_parse_both_parsers(tokenizer):
parser = make_parser(tokenizer, reasoning=True, tool=True)
request = make_request(tools=TOOLS)
reasoning, content, tool_calls = parser.parse(
MODEL_OUTPUT, request, enable_auto_tools=True
)

assert reasoning is not None
assert "let me think about this" in reasoning
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0].name == "get_weather"
assert json.loads(tool_calls[0].arguments) == {"city": "Dallas"}
assert not content or content.strip() == ""


def test_parse_reasoning_only(tokenizer):
parser = make_parser(tokenizer, reasoning=True, tool=False)
request = make_request()
reasoning, content, tool_calls = parser.parse(MODEL_OUTPUT, request)

assert reasoning is not None
assert "let me think about this" in reasoning
assert content is not None
assert "<tool_call>" in content
assert "get_weather" in content
assert tool_calls is not None
assert len(tool_calls) == 0


def test_parse_tool_only(tokenizer):
parser = make_parser(tokenizer, reasoning=False, tool=True)
request = make_request(tools=TOOLS)
reasoning, content, tool_calls = parser.parse(
MODEL_OUTPUT, request, enable_auto_tools=True
)

assert reasoning is None
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0].name == "get_weather"
assert json.loads(tool_calls[0].arguments) == {"city": "Dallas"}


def test_parse_named_tool_choice(tokenizer):
parser = make_parser(tokenizer, reasoning=False, tool=True)
request = make_request(
tools=TOOLS,
tool_choice={
"type": "function",
"function": {"name": "get_weather"},
},
)
reasoning, content, tool_calls = parser.parse(
TOOL_ARGUMENTS, request, enable_auto_tools=True
)

assert reasoning is None
assert content is None
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0].name == "get_weather"
assert tool_calls[0].arguments == TOOL_ARGUMENTS


def test_parse_named_tool_choice_with_reasoning(tokenizer):
parser = make_parser(tokenizer, reasoning=True, tool=True)
model_output = f"<think>thinking</think>{TOOL_ARGUMENTS}"
request = make_request(
tools=TOOLS,
tool_choice={
"type": "function",
"function": {"name": "get_weather"},
},
)
reasoning, content, tool_calls = parser.parse(
model_output, request, enable_auto_tools=True
)

assert reasoning is not None
assert "thinking" in reasoning
assert content is None
assert len(tool_calls) == 1
assert tool_calls[0].name == "get_weather"
assert tool_calls[0].arguments == TOOL_ARGUMENTS


def test_parse_required_tool_choice(tokenizer):
parser = make_parser(tokenizer, reasoning=False, tool=True)
functions_json = json.dumps(
[
{"name": "get_weather", "parameters": {"city": "Dallas"}},
{"name": "get_time", "parameters": {"timezone": "UTC"}},
]
)
request = make_request(tools=TOOLS, tool_choice="required")
reasoning, content, tool_calls = parser.parse(
functions_json, request, enable_auto_tools=True
)

assert reasoning is None
assert content is None
assert tool_calls is not None
assert len(tool_calls) == 2
assert tool_calls[0].name == "get_weather"
assert json.loads(tool_calls[0].arguments) == {"city": "Dallas"}
assert tool_calls[1].name == "get_time"
assert json.loads(tool_calls[1].arguments) == {"timezone": "UTC"}


def test_parse_named_tool_choice_content_none(tokenizer):
parser = make_parser(tokenizer, reasoning=False, tool=True)
request = make_request(
tools=TOOLS,
tool_choice={
"type": "function",
"function": {"name": "get_weather"},
},
)
reasoning, content, tool_calls = parser.parse("", request, enable_auto_tools=True)
assert reasoning is None
assert content is None
assert tool_calls is not None


def test_parse_required_tool_choice_content_none(tokenizer):
parser = make_parser(tokenizer, reasoning=False, tool=True)
request = make_request(tools=TOOLS, tool_choice="required")
reasoning, content, tool_calls = parser.parse("", request, enable_auto_tools=True)
assert reasoning is None
assert content is None
assert tool_calls is not None
assert len(tool_calls) == 0


def test_parse_auto_tools_no_parser(tokenizer):
parser = make_parser(tokenizer, reasoning=False, tool=False)
request = make_request()
reasoning, content, tool_calls = parser.parse(
TOOL_CALL_ONLY, request, enable_auto_tools=True
)

assert reasoning is None
assert content == TOOL_CALL_ONLY
assert tool_calls is not None
assert len(tool_calls) == 0


def test_parse_auto_tools_no_calls_returns_none(tokenizer):
parser = make_parser(tokenizer, reasoning=False, tool=True)
request = make_request(tools=TOOLS)
reasoning, content, tool_calls = parser.parse(
PLAIN_TEXT, request, enable_auto_tools=True
)

assert reasoning is None
assert content == PLAIN_TEXT
assert tool_calls is None
32 changes: 16 additions & 16 deletions vllm/entrypoints/openai/chat_completion/serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,14 @@ async def _create_chat_completion(
assert len(generators) == 1
(result_generator,) = generators

parser: Parser | None = None
if self.parser_cls is not None:
parser = self.parser_cls(
tokenizer,
request.tools,
chat_template_kwargs=chat_template_kwargs,
)

if request.stream:
return self.chat_completion_stream_generator(
request,
Expand All @@ -378,7 +386,7 @@ async def _create_chat_completion(
conversation,
tokenizer,
request_metadata,
reasoning_parser,
parser,
)

def get_chat_request_role(self, request: ChatCompletionRequest) -> str:
Expand Down Expand Up @@ -934,7 +942,7 @@ async def chat_completion_full_generator(
conversation: list[ConversationMessage],
tokenizer: TokenizerLike,
request_metadata: RequestResponseMetadata,
reasoning_parser: ReasoningParser | None = None,
parser: Parser | None = None,
) -> ErrorResponse | ChatCompletionResponse:
created_time = int(time.time())
final_res: RequestOutput | None = None
Expand Down Expand Up @@ -1043,28 +1051,20 @@ async def chat_completion_full_generator(
choices.append(choice_data)
continue

if reasoning_parser:
# If the reasoning parser is enabled,
# tool calls are extracted exclusively from the content.
reasoning, content = reasoning_parser.extract_reasoning(
output.text, request=request
if parser is not None:
reasoning, content, tool_calls = parser.parse(
output.text,
request,
enable_auto_tools=self.enable_auto_tools,
)
if not request.include_reasoning:
reasoning = None
else:
reasoning = None
content = output.text
tool_calls = []
Comment thread
sfeng33 marked this conversation as resolved.

auto_tools_called = False
# if auto tools are not enabled, and a named tool choice using
# outlines is not being used
tool_calls, content = self._parse_tool_calls_from_content(
request=request,
tokenizer=tokenizer,
content=content,
enable_auto_tools=self.enable_auto_tools,
tool_parser_cls=self.tool_parser,
)
if is_mistral_tokenizer(tokenizer):
from vllm.tool_parsers.mistral_tool_parser import MistralToolCall

Expand Down
Loading
Loading