1178 lines
41 KiB
Python
1178 lines
41 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Tests for the engine-based Qwen3 tool call parser.
|
|
|
|
These validate that the engine-driven parser correctly handles
|
|
Qwen3 XML-style tool calls.
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from tests.parser.engine.conftest import make_mock_tokenizer
|
|
from tests.parser.engine.streaming_helpers import (
|
|
collect_content,
|
|
collect_function_name,
|
|
collect_tool_arguments,
|
|
simulate_tool_streaming,
|
|
)
|
|
from vllm.parser.engine.parser_engine import ParserEngine
|
|
from vllm.parser.qwen3 import (
|
|
TOOL_CALL_END,
|
|
TOOL_CALL_START,
|
|
qwen3_config,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_tokenizer():
|
|
return make_mock_tokenizer(
|
|
{
|
|
TOOL_CALL_START: 100,
|
|
TOOL_CALL_END: 101,
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def parser(mock_tokenizer):
|
|
return ParserEngine(
|
|
mock_tokenizer,
|
|
parser_engine_config=qwen3_config(thinking=False),
|
|
)
|
|
|
|
|
|
class TestNonStreaming:
|
|
def test_no_tool_calls(self, parser, mock_request):
|
|
result = parser.extract_tool_calls(
|
|
"This is a regular response without any tool calls.",
|
|
mock_request,
|
|
)
|
|
assert result.tools_called is False
|
|
assert result.tool_calls == []
|
|
assert result.content == ("This is a regular response without any tool calls.")
|
|
|
|
def test_single_tool_call(self, parser, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=get_weather>\n"
|
|
"<parameter=city>Tokyo</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == "get_weather"
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args == {"city": "Tokyo"}
|
|
|
|
def test_parallel_tool_calls(self, parser, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=get_weather>\n"
|
|
"<parameter=city>Tokyo</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
"<tool_call>\n"
|
|
"<function=get_time>\n"
|
|
"<parameter=timezone>Asia/Tokyo</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
assert len(result.tool_calls) == 2
|
|
assert result.tool_calls[0].function.name == "get_weather"
|
|
assert result.tool_calls[1].function.name == "get_time"
|
|
|
|
args0 = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args0 == {"city": "Tokyo"}
|
|
args1 = json.loads(result.tool_calls[1].function.arguments)
|
|
assert args1 == {"timezone": "Asia/Tokyo"}
|
|
|
|
def test_various_data_types(self, parser, mock_request):
|
|
text = (
|
|
"<tool_call>\n<function=test_function>\n"
|
|
"<parameter=string_field>hello</parameter>\n"
|
|
"<parameter=int_field>42</parameter>\n"
|
|
"<parameter=float_field>3.14</parameter>\n"
|
|
"<parameter=bool_field>true</parameter>\n"
|
|
"<parameter=null_field>null</parameter>\n"
|
|
'<parameter=array_field>["a", "b", "c"]</parameter>\n'
|
|
'<parameter=object_field>{"nested": "value"}</parameter>\n'
|
|
"</function>\n</tool_call>"
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["string_field"] == "hello"
|
|
assert args["int_field"] == "42"
|
|
assert args["float_field"] == "3.14"
|
|
assert args["bool_field"] == "true"
|
|
assert args["null_field"] == "null"
|
|
assert args["array_field"] == '["a", "b", "c"]'
|
|
assert args["object_field"] == '{"nested": "value"}'
|
|
|
|
def test_empty_arguments(self, parser, mock_request):
|
|
text = "<tool_call>\n<function=refresh>\n</function>\n</tool_call>"
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
assert result.tool_calls[0].function.name == "refresh"
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args == {}
|
|
|
|
def test_surrounding_text(self, parser, mock_request):
|
|
text = (
|
|
"Let me check the weather for you.\n\n"
|
|
"<tool_call>\n<function=get_weather>\n"
|
|
"<parameter=city>Tokyo</parameter>\n"
|
|
"</function>\n</tool_call>\n\n"
|
|
"I will get that information."
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
assert result.content is not None
|
|
assert "Let me check the weather" in result.content
|
|
assert result.tool_calls[0].function.name == "get_weather"
|
|
|
|
def test_escaped_strings(self, parser, mock_request):
|
|
text = (
|
|
"<tool_call>\n<function=test_function>\n"
|
|
'<parameter=quoted>He said "hello"</parameter>\n'
|
|
"<parameter=path>C:\\Users\\file.txt</parameter>\n"
|
|
"<parameter=newline>line1\nline2</parameter>\n"
|
|
"</function>\n</tool_call>"
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["quoted"] == 'He said "hello"'
|
|
assert args["path"] == "C:\\Users\\file.txt"
|
|
assert args["newline"] == "line1\nline2"
|
|
|
|
def test_multiple_parameters(self, parser, mock_request):
|
|
text = (
|
|
"<tool_call>\n<function=search>\n"
|
|
"<parameter=query>vllm parsing</parameter>\n"
|
|
"<parameter=limit>10</parameter>\n"
|
|
"<parameter=exact_match>false</parameter>\n"
|
|
"</function>\n</tool_call>"
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args == {
|
|
"query": "vllm parsing",
|
|
"limit": "10",
|
|
"exact_match": "false",
|
|
}
|
|
|
|
def test_multiline_param_values(self, parser, mock_request):
|
|
"""Parameter values spanning multiple lines."""
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=Bash>\n"
|
|
"<parameter=command>\n"
|
|
"ls -la /tmp\n"
|
|
"</parameter>\n"
|
|
"<parameter=description>\n"
|
|
"List files in /tmp directory\n"
|
|
"</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == "Bash"
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["command"] == "ls -la /tmp"
|
|
assert args["description"] == "List files in /tmp directory"
|
|
|
|
def test_multiline_two_tool_calls(self, parser, mock_request):
|
|
"""Two tool calls with multi-line parameter values (bug report)."""
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=Bash>\n"
|
|
"<parameter=command>\n"
|
|
"find /workspace -name '*.py' | head -20\n"
|
|
"</parameter>\n"
|
|
"<parameter=description>\n"
|
|
"Find Python files\n"
|
|
"</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
"<tool_call>\n"
|
|
"<function=Read>\n"
|
|
"<parameter=file_path>\n"
|
|
"/workspace/main.py\n"
|
|
"</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
assert len(result.tool_calls) == 2
|
|
assert result.tool_calls[0].function.name == "Bash"
|
|
assert result.tool_calls[1].function.name == "Read"
|
|
args0 = json.loads(result.tool_calls[0].function.arguments)
|
|
assert "find /workspace" in args0["command"]
|
|
assert "Find Python files" in args0["description"]
|
|
args1 = json.loads(result.tool_calls[1].function.arguments)
|
|
assert "/workspace/main.py" in args1["file_path"]
|
|
|
|
def test_consecutive_tool_calls_without_tool_end(self, parser, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=get_weather>\n"
|
|
"<parameter=city>Tokyo</parameter>\n"
|
|
"</function>\n"
|
|
"<tool_call>\n"
|
|
"<function=get_weather>\n"
|
|
"<parameter=city>Paris</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
assert len(result.tool_calls) == 2
|
|
args0 = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args0 == {"city": "Tokyo"}
|
|
args1 = json.loads(result.tool_calls[1].function.arguments)
|
|
assert args1 == {"city": "Paris"}
|
|
|
|
def test_nested_json_array_parameter(self, parser, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=AskUserQuestion>\n"
|
|
"<parameter=questions>"
|
|
'[{"question": "Pick a color",'
|
|
' "multiSelect": false, "answer": null}]'
|
|
"</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args == {
|
|
"questions": '[{"question": "Pick a color",'
|
|
' "multiSelect": false, "answer": null}]',
|
|
}
|
|
|
|
|
|
class TestStreaming:
|
|
def test_basic_streaming(self, parser, mock_request):
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=get_weather>\n",
|
|
"<parameter=city>Tokyo",
|
|
"</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
name = collect_function_name(results)
|
|
assert name == "get_weather"
|
|
|
|
args_text = collect_tool_arguments(results)
|
|
assert args_text
|
|
parsed = json.loads(args_text)
|
|
assert parsed == {"city": "Tokyo"}
|
|
|
|
def test_streaming_multi_param(self, parser, mock_request):
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=get_weather>\n",
|
|
"<parameter=city>Tokyo</parameter>\n",
|
|
"<parameter=unit>celsius</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
name = collect_function_name(results)
|
|
assert name == "get_weather"
|
|
|
|
args_text = collect_tool_arguments(results)
|
|
assert args_text
|
|
parsed = json.loads(args_text)
|
|
assert parsed == {"city": "Tokyo", "unit": "celsius"}
|
|
|
|
def test_streaming_args_arrive_incrementally(self, parser, mock_request):
|
|
"""Arguments must stream as intermediate deltas, not batch at
|
|
tool-end."""
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=get_weather>\n",
|
|
"<parameter=city>Tokyo</parameter>\n",
|
|
"<parameter=unit>celsius</parameter>\n",
|
|
"<parameter=days>5</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
arg_deltas: list[str] = []
|
|
for delta, _ in results:
|
|
if delta and delta.tool_calls:
|
|
for tc in delta.tool_calls:
|
|
if tc.function and tc.function.arguments:
|
|
arg_deltas.append(tc.function.arguments)
|
|
|
|
assert len(arg_deltas) > 1, (
|
|
f"Expected arguments across multiple deltas, got {len(arg_deltas)}: "
|
|
f"{arg_deltas}"
|
|
)
|
|
concatenated = "".join(arg_deltas)
|
|
parsed = json.loads(concatenated)
|
|
assert parsed == {"city": "Tokyo", "unit": "celsius", "days": "5"}
|
|
|
|
def test_streaming_long_string_arg_before_parameter_end(self, parser, mock_request):
|
|
"""Long string arguments should stream before the closing parameter tag."""
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=write_report>\n",
|
|
"<parameter=content>",
|
|
"Artificial intelligence has rapidly transformed the way ",
|
|
"developers build dynamic applications with external tools.",
|
|
"</param",
|
|
"eter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
pre_close_arg_deltas: list[str] = []
|
|
all_arg_deltas: list[str] = []
|
|
for idx, (delta, _) in enumerate(results):
|
|
if delta and delta.tool_calls:
|
|
for tc in delta.tool_calls:
|
|
if tc.function and tc.function.arguments:
|
|
all_arg_deltas.append(tc.function.arguments)
|
|
if idx < 5:
|
|
pre_close_arg_deltas.append(tc.function.arguments)
|
|
|
|
assert len(pre_close_arg_deltas) > 1, (
|
|
"Expected long string arguments to stream incrementally before "
|
|
f"</parameter>, got {pre_close_arg_deltas}"
|
|
)
|
|
partial_args = "".join(pre_close_arg_deltas)
|
|
assert partial_args.startswith('{"content": "Artificial intelligence')
|
|
assert partial_args.endswith("external tools.")
|
|
assert not partial_args.endswith('"}')
|
|
|
|
all_args = "".join(all_arg_deltas)
|
|
assert json.loads(all_args) == {
|
|
"content": (
|
|
"Artificial intelligence has rapidly transformed the way "
|
|
"developers build dynamic applications with external tools."
|
|
)
|
|
}
|
|
|
|
def test_streaming_text_before_tool(self, parser, mock_request):
|
|
chunks = [
|
|
"Let me check ",
|
|
"the weather. ",
|
|
"<tool_call>\n",
|
|
"<function=get_weather>\n",
|
|
"<parameter=city>Tokyo</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
assert collect_content(results).strip().startswith("Let me check")
|
|
|
|
def test_streaming_empty_args(self, parser, mock_request):
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=refresh>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
name = collect_function_name(results)
|
|
assert name == "refresh"
|
|
|
|
def test_streaming_split_parameter_tag(self, parser, mock_request):
|
|
"""Parameter tag split across chunks."""
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=test>\n",
|
|
"<parameter=",
|
|
"name>Alice",
|
|
"</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
name = collect_function_name(results)
|
|
assert name == "test"
|
|
|
|
args_text = collect_tool_arguments(results)
|
|
assert args_text
|
|
parsed = json.loads(args_text)
|
|
assert parsed["name"] == "Alice"
|
|
|
|
def test_streaming_split_next_parameter_tag_is_buffered(self, parser, mock_request):
|
|
"""A split opening parameter tag must not leak into previous value."""
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=search>\n",
|
|
"<parameter=query>hello ",
|
|
"<param",
|
|
"eter=limit>10</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
args_after_partial_tag = collect_tool_arguments(results[:4])
|
|
assert "<param" not in args_after_partial_tag
|
|
assert args_after_partial_tag == '{"query": "hello'
|
|
|
|
args_text = collect_tool_arguments(results)
|
|
assert json.loads(args_text) == {"query": "hello", "limit": "10"}
|
|
|
|
def test_streaming_numeric_values(self, parser, mock_request):
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=set_config>\n",
|
|
"<parameter=count>42</parameter>\n",
|
|
"<parameter=active>true</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
args_text = collect_tool_arguments(results)
|
|
if args_text:
|
|
parsed = json.loads(args_text)
|
|
assert parsed["count"] == "42"
|
|
assert parsed["active"] == "true"
|
|
|
|
def test_streaming_parallel_calls(self, parser, mock_request):
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=get_weather>\n",
|
|
"<parameter=city>Tokyo</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
"<tool_call>\n",
|
|
"<function=get_time>\n",
|
|
"<parameter=tz>JST</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
names = []
|
|
for delta, _ in results:
|
|
if delta and delta.tool_calls:
|
|
for tc in delta.tool_calls:
|
|
if tc.function and tc.function.name:
|
|
names.append(tc.function.name)
|
|
|
|
assert "get_weather" in names
|
|
assert "get_time" in names
|
|
|
|
def test_streaming_value_split_across_chunks(self, parser, mock_request):
|
|
"""Parameter value split across multiple chunks."""
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=search>\n",
|
|
"<parameter=query>hello ",
|
|
"world",
|
|
" test</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
args_text = collect_tool_arguments(results)
|
|
assert args_text
|
|
parsed = json.loads(args_text)
|
|
assert parsed["query"] == "hello world test"
|
|
|
|
def test_streaming_split_tool_call_tag(self, parser, mock_request):
|
|
"""<tool_call> arrives as a single special token; the rest of
|
|
the content is split into fine-grained chunks."""
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=test>\n",
|
|
"<parameter=x>1",
|
|
"</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
name = collect_function_name(results)
|
|
assert name == "test"
|
|
|
|
args_text = collect_tool_arguments(results)
|
|
assert args_text
|
|
parsed = json.loads(args_text)
|
|
assert parsed["x"] == "1"
|
|
|
|
def test_char_by_char_streaming(self, mock_request):
|
|
"""Feed text character-by-character to test lexer robustness.
|
|
|
|
Uses a tokenizer without special token IDs because char-by-char
|
|
delivery only occurs when the tokenizer splits the tag across
|
|
multiple sub-word tokens (i.e., no dedicated special token).
|
|
"""
|
|
tokenizer = MagicMock()
|
|
tokenizer.encode.return_value = [1, 2, 3]
|
|
tokenizer.get_vocab.return_value = {}
|
|
tokenizer.decode.side_effect = lambda ids: "".join(
|
|
chr(i) if i < 128 else f"<{i}>" for i in ids
|
|
)
|
|
no_tid_parser = ParserEngine(
|
|
tokenizer, parser_engine_config=qwen3_config(thinking=False)
|
|
)
|
|
|
|
full_text = (
|
|
"<tool_call>\n"
|
|
"<function=echo>\n"
|
|
"<parameter=msg>hi</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
chunks = list(full_text)
|
|
results = simulate_tool_streaming(no_tid_parser, mock_request, chunks)
|
|
|
|
name = collect_function_name(results)
|
|
assert name == "echo"
|
|
|
|
args_text = collect_tool_arguments(results)
|
|
assert args_text
|
|
parsed = json.loads(args_text)
|
|
assert parsed == {"msg": "hi"}
|
|
|
|
def test_streaming_multiline_param_values(self, parser, mock_request):
|
|
"""Multi-line parameter values in streaming mode."""
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=Bash>\n",
|
|
"<parameter=command>\n",
|
|
"ls -la /tmp\n",
|
|
"</parameter>\n",
|
|
"<parameter=description>\n",
|
|
"List files\n",
|
|
"</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
name = collect_function_name(results)
|
|
assert name == "Bash"
|
|
|
|
args_text = collect_tool_arguments(results)
|
|
assert args_text
|
|
parsed = json.loads(args_text)
|
|
assert "ls -la /tmp" in parsed["command"]
|
|
assert "List files" in parsed["description"]
|
|
|
|
def test_streaming_multiline_two_tool_calls(self, parser, mock_request):
|
|
"""Two tool calls with multi-line values — matches bug report."""
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=Bash>\n",
|
|
"<parameter=command>\n",
|
|
"find /workspace -name '*.py' | head -20\n",
|
|
"</parameter>\n",
|
|
"<parameter=description>\n",
|
|
"Find Python files\n",
|
|
"</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
"<tool_call>\n",
|
|
"<function=Read>\n",
|
|
"<parameter=file_path>\n",
|
|
"/workspace/main.py\n",
|
|
"</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
results = simulate_tool_streaming(parser, mock_request, chunks)
|
|
|
|
names = []
|
|
for delta, _ in results:
|
|
if delta and delta.tool_calls:
|
|
for tc in delta.tool_calls:
|
|
if tc.function and tc.function.name:
|
|
names.append(tc.function.name)
|
|
|
|
assert "Bash" in names
|
|
assert "Read" in names
|
|
|
|
|
|
class TestArgConverter:
|
|
"""Direct tests for the Qwen3 arg_converter with multi-line values."""
|
|
|
|
def test_multiline_param_values(self):
|
|
from vllm.parser.qwen3 import (
|
|
_qwen3_arg_converter,
|
|
)
|
|
|
|
raw = (
|
|
"<parameter=command>\n"
|
|
"ls -la /tmp\n"
|
|
"</parameter>\n"
|
|
"<parameter=description>\n"
|
|
"List files\n"
|
|
"</parameter>\n"
|
|
)
|
|
result = json.loads(_qwen3_arg_converter(raw, partial=False))
|
|
assert result["command"] == "ls -la /tmp"
|
|
assert result["description"] == "List files"
|
|
|
|
def test_two_multiline_params(self):
|
|
from vllm.parser.qwen3 import (
|
|
_qwen3_arg_converter,
|
|
)
|
|
|
|
raw = (
|
|
"<parameter=a>\nfoo\nbar\n</parameter>\n"
|
|
"<parameter=b>\nbaz\nqux\n</parameter>\n"
|
|
)
|
|
result = json.loads(_qwen3_arg_converter(raw, partial=False))
|
|
assert result["a"] == "foo\nbar"
|
|
assert result["b"] == "baz\nqux"
|
|
|
|
def test_partial_multiline(self):
|
|
from vllm.parser.qwen3 import (
|
|
_qwen3_arg_converter,
|
|
)
|
|
|
|
raw = "<parameter=command>\nls -la</parameter>\n<parameter=desc>\npartial value"
|
|
result = json.loads(_qwen3_arg_converter(raw, partial=True))
|
|
assert result["command"] == "ls -la"
|
|
assert result["desc"] == "partial value"
|
|
|
|
def test_partial_value_with_angle_bracket(self):
|
|
from vllm.parser.qwen3 import (
|
|
_qwen3_arg_converter,
|
|
)
|
|
|
|
raw = "<parameter=expr>x<5"
|
|
result = json.loads(_qwen3_arg_converter(raw, partial=True))
|
|
assert result == {"expr": "x<5"}
|
|
|
|
def test_partial_value_with_angle_bracket_and_complete_param(self):
|
|
from vllm.parser.qwen3 import (
|
|
_qwen3_arg_converter,
|
|
)
|
|
|
|
raw = "<parameter=city>Tokyo</parameter>\n<parameter=expr>x<5"
|
|
result = json.loads(_qwen3_arg_converter(raw, partial=True))
|
|
assert result == {"city": "Tokyo", "expr": "x<5"}
|
|
|
|
|
|
class TestSchemaAwareTypeCoercion:
|
|
"""Verify that _fix_arg_types corrects miscoerced values using the
|
|
tool schema."""
|
|
|
|
@pytest.fixture
|
|
def tools(self):
|
|
from vllm.entrypoints.openai.chat_completion.protocol import (
|
|
ChatCompletionToolsParam,
|
|
)
|
|
|
|
return [
|
|
ChatCompletionToolsParam(
|
|
type="function",
|
|
function={
|
|
"name": "TaskUpdate",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"taskId": {"type": "string"},
|
|
"count": {"type": "integer"},
|
|
"ratio": {"type": "number"},
|
|
"flag": {"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
]
|
|
|
|
@pytest.fixture
|
|
def parser_with_tools(self, mock_tokenizer, tools):
|
|
return ParserEngine(
|
|
mock_tokenizer,
|
|
tools=tools,
|
|
parser_engine_config=qwen3_config(thinking=False),
|
|
)
|
|
|
|
def test_string_param_not_coerced_to_int(self, parser_with_tools, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=TaskUpdate>\n"
|
|
"<parameter=taskId>1</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
assert result.tools_called
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["taskId"] == "1"
|
|
assert isinstance(args["taskId"], str)
|
|
|
|
def test_string_param_not_coerced_to_bool(self, parser_with_tools, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=TaskUpdate>\n"
|
|
"<parameter=flag>true</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["flag"] == "true"
|
|
assert isinstance(args["flag"], str)
|
|
|
|
def test_int_param_still_coerced(self, parser_with_tools, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=TaskUpdate>\n"
|
|
"<parameter=count>42</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["count"] == 42
|
|
assert isinstance(args["count"], int)
|
|
|
|
def test_no_tools_keeps_strings(self, parser, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=TaskUpdate>\n"
|
|
"<parameter=taskId>1</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["taskId"] == "1"
|
|
assert isinstance(args["taskId"], str)
|
|
|
|
def test_streaming_string_param_not_coerced(self, parser_with_tools, mock_request):
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=TaskUpdate>\n",
|
|
"<parameter=taskId>1</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
results = simulate_tool_streaming(parser_with_tools, mock_request, chunks)
|
|
args_str = collect_tool_arguments(results)
|
|
args = json.loads(args_str)
|
|
assert args["taskId"] == "1"
|
|
assert isinstance(args["taskId"], str)
|
|
|
|
|
|
class TestAnyOfTypeCoercion:
|
|
"""Verify that _fix_arg_types handles union types (anyOf/oneOf)."""
|
|
|
|
@pytest.fixture
|
|
def tools_with_anyof(self):
|
|
from vllm.entrypoints.openai.chat_completion.protocol import (
|
|
ChatCompletionToolsParam,
|
|
)
|
|
|
|
return [
|
|
ChatCompletionToolsParam(
|
|
type="function",
|
|
function={
|
|
"name": "set_config",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"port": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{"type": "null"},
|
|
],
|
|
},
|
|
"count": {"type": "integer"},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
]
|
|
|
|
@pytest.fixture
|
|
def parser_with_anyof(self, mock_tokenizer, tools_with_anyof):
|
|
return ParserEngine(
|
|
mock_tokenizer,
|
|
tools=tools_with_anyof,
|
|
parser_engine_config=qwen3_config(thinking=False),
|
|
)
|
|
|
|
def test_anyof_string_param_not_coerced(self, parser_with_anyof, mock_request):
|
|
"""A param with anyOf including 'string' must not be coerced
|
|
to integer."""
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=set_config>\n"
|
|
"<parameter=port>8080</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_anyof.extract_tool_calls(text, mock_request)
|
|
assert result.tools_called
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["port"] == "8080"
|
|
|
|
|
|
class TestSchemaCoercionBoolNumberNull:
|
|
"""Verify that _fix_arg_types coerces string values to non-string
|
|
schema types using coerce_to_schema_type."""
|
|
|
|
@pytest.fixture
|
|
def tools(self):
|
|
from vllm.entrypoints.openai.chat_completion.protocol import (
|
|
ChatCompletionToolsParam,
|
|
)
|
|
|
|
return [
|
|
ChatCompletionToolsParam(
|
|
type="function",
|
|
function={
|
|
"name": "configure",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"enabled": {"type": "boolean"},
|
|
"ratio": {"type": "number"},
|
|
"count": {"type": "integer"},
|
|
"value": {"type": ["integer", "null"]},
|
|
"label": {"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
]
|
|
|
|
@pytest.fixture
|
|
def parser_with_tools(self, mock_tokenizer, tools):
|
|
return ParserEngine(
|
|
mock_tokenizer,
|
|
tools=tools,
|
|
parser_engine_config=qwen3_config(thinking=False),
|
|
)
|
|
|
|
def test_bool_param_coerced(self, parser_with_tools, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=configure>\n"
|
|
"<parameter=enabled>true</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["enabled"] is True
|
|
assert isinstance(args["enabled"], bool)
|
|
|
|
def test_number_param_whole_normalized(self, parser_with_tools, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=configure>\n"
|
|
"<parameter=ratio>5.0</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["ratio"] == 5
|
|
assert isinstance(args["ratio"], int)
|
|
|
|
def test_number_param_fractional(self, parser_with_tools, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=configure>\n"
|
|
"<parameter=ratio>3.14</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["ratio"] == pytest.approx(3.14)
|
|
assert isinstance(args["ratio"], float)
|
|
|
|
def test_null_coerced_when_in_schema(self, parser_with_tools, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=configure>\n"
|
|
"<parameter=value>null</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["value"] is None
|
|
|
|
def test_null_stays_string_without_null_schema(
|
|
self, parser_with_tools, mock_request
|
|
):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=configure>\n"
|
|
"<parameter=label>null</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["label"] == "null"
|
|
assert isinstance(args["label"], str)
|
|
|
|
def test_streaming_bool_param_coerced(self, parser_with_tools, mock_request):
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=configure>\n",
|
|
"<parameter=enabled>true</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
results = simulate_tool_streaming(parser_with_tools, mock_request, chunks)
|
|
args_str = collect_tool_arguments(results)
|
|
args = json.loads(args_str)
|
|
assert args["enabled"] is True
|
|
assert isinstance(args["enabled"], bool)
|
|
|
|
def test_streaming_number_param_coerced(self, parser_with_tools, mock_request):
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=configure>\n",
|
|
"<parameter=ratio>3.14</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
results = simulate_tool_streaming(parser_with_tools, mock_request, chunks)
|
|
args_str = collect_tool_arguments(results)
|
|
args = json.loads(args_str)
|
|
assert args["ratio"] == pytest.approx(3.14)
|
|
assert isinstance(args["ratio"], float)
|
|
|
|
def test_streaming_matches_non_streaming_comprehensive(
|
|
self, parser_with_tools, mock_request
|
|
):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=configure>\n"
|
|
"<parameter=enabled>true</parameter>\n"
|
|
"<parameter=ratio>5.0</parameter>\n"
|
|
"<parameter=count>42</parameter>\n"
|
|
"<parameter=value>null</parameter>\n"
|
|
"<parameter=label>hello</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
non_stream = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
ns_args = json.loads(non_stream.tool_calls[0].function.arguments)
|
|
|
|
chunks = [line + "\n" for line in text.split("\n") if line]
|
|
results = simulate_tool_streaming(parser_with_tools, mock_request, chunks)
|
|
s_args = json.loads(collect_tool_arguments(results))
|
|
|
|
assert s_args == ns_args
|
|
assert ns_args == {
|
|
"enabled": True,
|
|
"ratio": 5,
|
|
"count": 42,
|
|
"value": None,
|
|
"label": "hello",
|
|
}
|
|
|
|
|
|
class TestNestedSchemaCoercion:
|
|
"""Verify that _fix_arg_types recurses into nested objects and arrays."""
|
|
|
|
@pytest.fixture
|
|
def tools(self):
|
|
from vllm.entrypoints.openai.chat_completion.protocol import (
|
|
ChatCompletionToolsParam,
|
|
)
|
|
|
|
return [
|
|
ChatCompletionToolsParam(
|
|
type="function",
|
|
function={
|
|
"name": "search",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
"filters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"language": {"type": "string"},
|
|
"min_stars": {"type": "integer"},
|
|
},
|
|
},
|
|
"tags": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
},
|
|
"limits": {
|
|
"type": "array",
|
|
"items": {"type": "integer"},
|
|
},
|
|
"verbose": {"type": "boolean"},
|
|
},
|
|
},
|
|
},
|
|
),
|
|
ChatCompletionToolsParam(
|
|
type="function",
|
|
function={
|
|
"name": "AskUserQuestion",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"questions": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"question": {"type": "string"},
|
|
"multiSelect": {
|
|
"type": "boolean",
|
|
},
|
|
"answer": {
|
|
"type": ["string", "null"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
),
|
|
]
|
|
|
|
@pytest.fixture
|
|
def parser_with_tools(self, mock_tokenizer, tools):
|
|
return ParserEngine(
|
|
mock_tokenizer,
|
|
tools=tools,
|
|
parser_engine_config=qwen3_config(thinking=False),
|
|
)
|
|
|
|
def test_nested_object_coerced(self, parser_with_tools, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=search>\n"
|
|
'<parameter=filters>{"language": "python",'
|
|
' "min_stars": 100}</parameter>\n'
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["filters"] == {"language": "python", "min_stars": 100}
|
|
assert isinstance(args["filters"]["min_stars"], int)
|
|
|
|
def test_nested_array_items_coerced(self, parser_with_tools, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=search>\n"
|
|
"<parameter=limits>[10, 20, 30]</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["limits"] == [10, 20, 30]
|
|
assert all(isinstance(v, int) for v in args["limits"])
|
|
|
|
def test_nested_string_array_not_coerced(self, parser_with_tools, mock_request):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=search>\n"
|
|
'<parameter=tags>["ml", "42"]</parameter>\n'
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args["tags"] == ["ml", "42"]
|
|
assert all(isinstance(v, str) for v in args["tags"])
|
|
|
|
def test_array_of_objects_with_bool_and_null_coerced(
|
|
self, parser_with_tools, mock_request
|
|
):
|
|
text = (
|
|
"<tool_call>\n"
|
|
"<function=AskUserQuestion>\n"
|
|
"<parameter=questions>"
|
|
'[{"question": "Pick a color",'
|
|
' "multiSelect": false, "answer": null}]'
|
|
"</parameter>\n"
|
|
"</function>\n"
|
|
"</tool_call>"
|
|
)
|
|
result = parser_with_tools.extract_tool_calls(text, mock_request)
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
questions = args["questions"]
|
|
assert isinstance(questions, list)
|
|
assert len(questions) == 1
|
|
assert questions[0]["question"] == "Pick a color"
|
|
assert questions[0]["multiSelect"] is False
|
|
assert questions[0]["answer"] is None
|
|
|
|
def test_streaming_array_of_objects_with_bool_and_null_coerced(
|
|
self, parser_with_tools, mock_request
|
|
):
|
|
chunks = [
|
|
"<tool_call>\n",
|
|
"<function=AskUserQuestion>\n",
|
|
'<parameter=questions>[{"question": "Pick a color",',
|
|
' "multiSelect": false, "answer": null}]',
|
|
"</parameter>\n",
|
|
"</function>\n",
|
|
"</tool_call>",
|
|
]
|
|
results = simulate_tool_streaming(parser_with_tools, mock_request, chunks)
|
|
args_str = collect_tool_arguments(results)
|
|
args = json.loads(args_str)
|
|
questions = args["questions"]
|
|
assert isinstance(questions, list)
|
|
assert len(questions) == 1
|
|
assert questions[0]["question"] == "Pick a color"
|
|
assert questions[0]["multiSelect"] is False
|
|
assert questions[0]["answer"] is None
|