Skip to content
Open
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
3 changes: 2 additions & 1 deletion .buildkite/test_areas/rust_frontend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ steps:
--ignore=tool_use/mistral
--models llama3.2
# The Rust renderer rejects empty tool-call arguments in the history (Python treats them as {}).
- pytest -v -s entrypoints/openai/chat_completion/test_completion_with_function_calling.py -k "not test_no_args_tool_call"
# Under an answer constraint the Rust grammar keeps tool calls optional, so the regex does not force text after a call.
- pytest -v -s entrypoints/openai/chat_completion/test_completion_with_function_calling.py -k "not test_no_args_tool_call and not test_non_streaming_preserves_text_after_tool_call"
- pytest -v -s entrypoints/tool_parsers/test_granite4_tool_parser.py
# The llama cases use --lora-modules, which fails at startup until the Rust LoRA request drops tensorizer_config_dict.
- pytest -v -s entrypoints/tool_parsers/test_hermes_tool_parser.py -k granite4
Expand Down
2 changes: 2 additions & 0 deletions docs/features/tool_calling.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ that contain previously generated tool calls. Hermes, Mistral and Llama models h
template configured in the `tokenizer_config.json`. In this case, it will be used per the `transformers` specification. More on this [here](https://huggingface.co/docs/transformers/en/chat_templating#why-do-some-models-have-multiple-templates)
from HuggingFace; and you can find an example of this in a `tokenizer_config.json` [here](https://huggingface.co/NousResearch/Hermes-2-Pro-Llama-3-8B/blob/main/tokenizer_config.json).

With `qwen3_coder` and other engine-based tool parsers that keep assistant text in streaming responses, non-streaming responses return text written before, between, and after tool calls in `message.content`, next to the parsed calls in `message.tool_calls`. Some parsers, such as `deepseek_v4`, drop text that follows a tool call.

If your favorite tool-calling model is not supported, please feel free to contribute a parser & tool use chat template!

When a custom structured-output grammar includes reasoning or tool-call
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import openai # use the official client for correctness check
import pytest
import pytest_asyncio
import regex as re

# downloading lora to test lora requests
from tests.utils import ROCM_EXTRA_ARGS, RemoteOpenAIServer
Expand Down Expand Up @@ -127,7 +128,7 @@


@pytest.fixture(scope="module")
def server():
def server(request):
args = [
# use half precision for speed and memory savings in CI environment
"--dtype",
Expand All @@ -136,7 +137,7 @@ def server():
"--structured-outputs-config.backend",
"xgrammar",
"--tool-call-parser",
"hermes",
getattr(request, "param", "hermes"),
"--reasoning-parser",
"qwen3",
"--gpu-memory-utilization",
Expand Down Expand Up @@ -188,6 +189,37 @@ async def tool_use_client(tool_use_server):
yield async_client


@pytest.mark.asyncio
@pytest.mark.parametrize("server", ["qwen3_coder"], indirect=True)
async def test_non_streaming_preserves_text_after_tool_call(client: openai.AsyncOpenAI):
text = (
"<tool_call><function=get_current_weather>"
"<parameter=country>Austria</parameter>"
"<parameter=unit>celsius</parameter>"
"</function></tool_call>I will check the weather."
)
completion = await client.chat.completions.create(
model=MODEL_NAME,
messages=[{"role": "user", "content": "Check the weather in Austria."}],
tools=tools[:1],
tool_choice="auto",
temperature=0,
max_completion_tokens=128,
extra_body={
"chat_template_kwargs": {"enable_thinking": False},
"structured_outputs": {"regex": re.escape(text)},
},
)

choice = completion.choices[0]
assert choice.message.content == "I will check the weather."
assert choice.finish_reason == "tool_calls"
assert len(choice.message.tool_calls) == 1
tool_call = choice.message.tool_calls[0].function
assert tool_call.name == "get_current_weather"
assert json.loads(tool_call.arguments) == {"country": "Austria", "unit": "celsius"}


@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
@pytest.mark.parametrize("stream", [True, False])
Expand Down
3 changes: 1 addition & 2 deletions tests/parser/test_response_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -850,8 +850,7 @@ def test_gemma4_response_template_preserves_content_after_tool_call():
enable_auto_tools=True,
)

assert registered_content is None
assert metadata_content == "Done."
assert registered_content == metadata_content == "Done."
assert normalize_calls(metadata_calls) == normalize_calls(registered_calls)


Expand Down
26 changes: 26 additions & 0 deletions tests/tool_parsers/test_qwen3coder_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,32 @@ def stream_delta_message_generator(
read_offset = new_read_offset


@pytest.mark.parametrize(
"before,between,after",
[("", "", "Final answer."), (" Before. ", " Between. ", " After. ")],
)
def test_extract_tool_calls_keeps_text_around_tool_calls(
qwen3_tool_parser, before, between, after
):
"""A complete response must keep the same prose as a streaming response."""
model_output = (
f"{before}<tool_call><function=get_current_weather>"
"<parameter=city>Tokyo</parameter></function></tool_call>"
f"{between}<tool_call><function=calculate_area>"
"<parameter=shape>square</parameter></function></tool_call>"
f"{after}"
)
request = ChatCompletionRequest(model=MODEL, messages=[])

result = qwen3_tool_parser.extract_tool_calls(model_output, request=request)

assert result.content == (before + between + after).strip()
assert [call.function.name for call in result.tool_calls] == [
"get_current_weather",
"calculate_area",
]


def test_extract_tool_calls_no_tools(qwen3_tool_parser):
model_output = "This is a test response without any tool calls"
extracted_tool_calls = qwen3_tool_parser.extract_tool_calls(
Expand Down
2 changes: 1 addition & 1 deletion tests/tool_parsers/test_step3p5_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1034,7 +1034,7 @@ def test_extract_tool_calls_non_streaming_mixed_content_and_multiple_tool_calls(
assert args_dict_1["dimensions"]["width"] == 10
assert args_dict_1["dimensions"]["height"] == 5

assert extracted_tool_calls.content == "hello"
assert extracted_tool_calls.content == "hellohi"

# Verify that tool call tags are NOT in the content
assert "<function=get_current_weather>" not in extracted_tool_calls.content, (
Expand Down
5 changes: 5 additions & 0 deletions vllm/parser/engine/parser_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,11 @@ def _single_pass_parse(
reasoning = reasoning.rstrip() or None

content = delta.content if delta else None
# A complete parse must include text deferred for streaming ordering.
if self._deferred_content:
deferred_delta = self._events_to_delta([], finished=True)
if deferred_delta and deferred_delta.content:
content = (content or "") + deferred_delta.content
if content:
content = self._strip_content_whitespace(
content, tool_call_info.tools_called
Expand Down
27 changes: 0 additions & 27 deletions vllm/parser/inkling.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
from collections.abc import Sequence
from typing import TYPE_CHECKING

from vllm.entrypoints.generate.base.protocol import ExtractedToolCallInformation
from vllm.parser.engine.events import EventType
from vllm.parser.engine.parser_engine import ParserEngine
from vllm.parser.engine.parser_engine_config import (
Expand Down Expand Up @@ -294,32 +293,6 @@ def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
count += 1
return count

def _single_pass_parse(
self,
text: str,
token_ids: Sequence[int],
initial_state: ParserState | None = None,
) -> tuple[str | None, str | None, ExtractedToolCallInformation]:
reasoning, content, tool_call_info = super()._single_pass_parse(
text, token_ids, initial_state=initial_state
)
# The engine defers content that follows tool-call events within a
# single pass; Inkling allows text blocks after tool-call blocks, so
# flush the trailing text (matching the Rust unified parser).
if self._deferred_content:
trailing = self._deferred_content
self._deferred_content = ""
content = self._strip_content_whitespace(
(content or "") + trailing,
tool_call_info.tools_called,
)
tool_call_info = ExtractedToolCallInformation(
tools_called=tool_call_info.tools_called,
tool_calls=tool_call_info.tool_calls,
content=content,
)
return reasoning, content, tool_call_info

@staticmethod
def _extract_args_value(parsed: dict) -> str | None:
# Inkling wraps arguments under "args" rather than "arguments".
Expand Down
Loading