Files
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

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