Signed-off-by: Afonso Ribeiro <afonso.diamantino2@gmail.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>
386 lines
13 KiB
Python
386 lines
13 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
import dataclasses
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from tests.parser.engine.conftest import make_mock_tokenizer
|
|
from tests.parser.engine.streaming_helpers import (
|
|
collect_function_name,
|
|
collect_tool_arguments,
|
|
simulate_tool_streaming,
|
|
)
|
|
from vllm.entrypoints.openai.chat_completion.protocol import (
|
|
ChatCompletionToolsParam,
|
|
FunctionDefinition,
|
|
)
|
|
from vllm.parser.engine.parser_engine import ParserEngine
|
|
from vllm.parser.minimax_m2 import (
|
|
THINK_END,
|
|
TOOL_CALL_END,
|
|
TOOL_CALL_START,
|
|
MinimaxM2Parser,
|
|
_minimax_m2_arg_value_extender,
|
|
minimax_m2_config,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_tokenizer():
|
|
return make_mock_tokenizer(
|
|
{
|
|
THINK_END: 99,
|
|
TOOL_CALL_START: 100,
|
|
TOOL_CALL_END: 101,
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def parser(mock_tokenizer):
|
|
return MinimaxM2Parser(mock_tokenizer)
|
|
|
|
|
|
def make_tools(*names: str):
|
|
return [
|
|
ChatCompletionToolsParam(
|
|
function=FunctionDefinition(
|
|
name=name,
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
),
|
|
)
|
|
for name in names
|
|
]
|
|
|
|
|
|
class TestNonStreaming:
|
|
def test_no_tool_calls(self, parser, mock_request):
|
|
result = parser.extract_tool_calls(
|
|
"</think>This is a regular response without tool calls.",
|
|
mock_request,
|
|
)
|
|
assert result.tools_called is False
|
|
assert result.tool_calls == []
|
|
assert result.content == "This is a regular response without tool calls."
|
|
|
|
def test_single_tool_call(self, parser, mock_request):
|
|
result = parser.extract_tool_calls(
|
|
'<minimax:tool_call><invoke name="get_weather">'
|
|
'<parameter name="city">Seattle</parameter>'
|
|
"</invoke></minimax:tool_call>",
|
|
mock_request,
|
|
)
|
|
|
|
assert result.tools_called is True
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == "get_weather"
|
|
assert json.loads(result.tool_calls[0].function.arguments) == {
|
|
"city": "Seattle",
|
|
}
|
|
|
|
def test_multiple_invokes(self, parser, mock_request):
|
|
result = parser.extract_tool_calls(
|
|
"<minimax:tool_call>"
|
|
'<invoke name="search"><parameter name="q">OpenAI</parameter></invoke>'
|
|
'<invoke name="search"><parameter name="q">vLLM</parameter></invoke>'
|
|
"</minimax:tool_call>",
|
|
mock_request,
|
|
)
|
|
|
|
assert result.tools_called is True
|
|
assert [tc.function.name for tc in result.tool_calls] == ["search", "search"]
|
|
assert json.loads(result.tool_calls[0].function.arguments) == {"q": "OpenAI"}
|
|
assert json.loads(result.tool_calls[1].function.arguments) == {"q": "vLLM"}
|
|
|
|
def test_schema_type_coercion(self, mock_tokenizer, mock_request):
|
|
tools = [
|
|
ChatCompletionToolsParam(
|
|
function=FunctionDefinition(
|
|
name="forecast",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"days": {"type": "integer"},
|
|
"include_hourly": {"type": "boolean"},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
]
|
|
parser = MinimaxM2Parser(mock_tokenizer, tools=tools)
|
|
mock_request.tools = tools
|
|
|
|
result = parser.extract_tool_calls(
|
|
'<minimax:tool_call><invoke name="forecast">'
|
|
'<parameter name="days">5</parameter>'
|
|
'<parameter name="include_hourly">true</parameter>'
|
|
"</invoke></minimax:tool_call>",
|
|
mock_request,
|
|
)
|
|
|
|
assert json.loads(result.tool_calls[0].function.arguments) == {
|
|
"days": 5,
|
|
"include_hourly": True,
|
|
}
|
|
|
|
def test_invalid_tool_name_is_rejected(self, mock_tokenizer, mock_request):
|
|
tools = make_tools("search")
|
|
parser = MinimaxM2Parser(mock_tokenizer)
|
|
mock_request.tools = tools
|
|
|
|
result = parser.extract_tool_calls(
|
|
'<minimax:tool_call><invoke name="img_gen">'
|
|
'<parameter name="prompt">a cat</parameter>'
|
|
"</invoke></minimax:tool_call>",
|
|
mock_request,
|
|
)
|
|
|
|
assert result.tools_called is False
|
|
assert result.tool_calls == []
|
|
|
|
def test_mixed_tool_names_only_return_valid(self, mock_tokenizer, mock_request):
|
|
tools = make_tools("search")
|
|
parser = MinimaxM2Parser(mock_tokenizer)
|
|
mock_request.tools = tools
|
|
|
|
result = parser.extract_tool_calls(
|
|
"<minimax:tool_call>"
|
|
'<invoke name="img_gen"><parameter name="prompt">cat</parameter></invoke>'
|
|
'<invoke name="search"><parameter name="query">news</parameter></invoke>'
|
|
"</minimax:tool_call>",
|
|
mock_request,
|
|
)
|
|
|
|
assert result.tools_called is True
|
|
assert [tc.function.name for tc in result.tool_calls] == ["search"]
|
|
assert json.loads(result.tool_calls[0].function.arguments) == {
|
|
"query": "news",
|
|
}
|
|
|
|
|
|
class TestStreaming:
|
|
def test_streaming_single_tool_call(self, parser, mock_request):
|
|
results = simulate_tool_streaming(
|
|
parser,
|
|
mock_request,
|
|
[
|
|
"<minimax:tool_call>",
|
|
'<invoke name="get_weather">',
|
|
'<parameter name="city">Seattle</parameter>',
|
|
"</invoke></minimax:tool_call>",
|
|
],
|
|
)
|
|
|
|
assert collect_function_name(results) == "get_weather"
|
|
assert json.loads(collect_tool_arguments(results)) == {
|
|
"city": "Seattle",
|
|
}
|
|
|
|
def test_streaming_multiple_invokes(self, parser, mock_request):
|
|
results = simulate_tool_streaming(
|
|
parser,
|
|
mock_request,
|
|
[
|
|
"<minimax:tool_call>",
|
|
'<invoke name="a"><parameter name="x">1</parameter></invoke>',
|
|
'<invoke name="b"><parameter name="y">2</parameter></invoke>',
|
|
"</minimax:tool_call>",
|
|
],
|
|
)
|
|
|
|
tool_names = [
|
|
tc.function.name
|
|
for delta, _ in results
|
|
if delta and delta.tool_calls
|
|
for tc in delta.tool_calls
|
|
if tc.function and tc.function.name
|
|
]
|
|
assert tool_names == ["a", "b"]
|
|
|
|
def test_streaming_invoke_prefix_split_before_quote(self, parser, mock_request):
|
|
results = simulate_tool_streaming(
|
|
parser,
|
|
mock_request,
|
|
[
|
|
"<minimax:tool_call>",
|
|
"<invoke name=",
|
|
'"get_weather">',
|
|
'<parameter name="city">Seattle</parameter>',
|
|
"</invoke></minimax:tool_call>",
|
|
],
|
|
)
|
|
|
|
assert collect_function_name(results) == "get_weather"
|
|
assert json.loads(collect_tool_arguments(results)) == {
|
|
"city": "Seattle",
|
|
}
|
|
|
|
def test_streaming_invalid_tool_name_is_rejected(
|
|
self, mock_tokenizer, mock_request
|
|
):
|
|
tools = make_tools("search")
|
|
parser = MinimaxM2Parser(mock_tokenizer)
|
|
mock_request.tools = tools
|
|
|
|
results = simulate_tool_streaming(
|
|
parser,
|
|
mock_request,
|
|
[
|
|
"<minimax:tool_call>",
|
|
'<invoke name="img_gen">',
|
|
'<parameter name="prompt">cat</parameter>',
|
|
"</invoke></minimax:tool_call>",
|
|
],
|
|
)
|
|
|
|
assert collect_function_name(results) is None
|
|
assert collect_tool_arguments(results) == ""
|
|
|
|
|
|
WRITE_CALL_HEAD = (
|
|
'<minimax:tool_call>\n<invoke name="write_file">\n'
|
|
'<parameter name="path">/repo/app.py</parameter>\n'
|
|
'<parameter name="mode">644</parameter>\n'
|
|
'<parameter name="content">'
|
|
)
|
|
WRITE_CALL_TAIL = "</parameter>\n</invoke>\n</minimax:tool_call>"
|
|
# Escapes, non-ASCII, blank lines and tag-like text that must stay inside
|
|
# the value, including prefixes of the closing and next-parameter tags.
|
|
CONTENT_LINE = 'if a < b > c: print("x\\y") # </p> </param <parameter\té\n\n'
|
|
|
|
|
|
class TestArgValueExtender:
|
|
"""The open parameter value streams without re-running the converter."""
|
|
|
|
@pytest.fixture
|
|
def write_tool(self):
|
|
return ChatCompletionToolsParam(
|
|
function=FunctionDefinition(
|
|
name="write_file",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string"},
|
|
"mode": {"type": "integer"},
|
|
"content": {"type": "string"},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
|
|
@pytest.mark.parametrize("use_schema", [False, True], ids=["no_schema", "schema"])
|
|
def test_long_call_streams_non_streaming_arguments(
|
|
self, mock_tokenizer, mock_request, write_tool, use_schema
|
|
):
|
|
mock_request.tools = [write_tool] if use_schema else []
|
|
text = WRITE_CALL_HEAD + CONTENT_LINE * 200 + WRITE_CALL_TAIL
|
|
|
|
results = simulate_tool_streaming(
|
|
MinimaxM2Parser(mock_tokenizer),
|
|
mock_request,
|
|
[text[i : i + 7] for i in range(0, len(text), 7)],
|
|
)
|
|
result = MinimaxM2Parser(mock_tokenizer).extract_tool_calls(text, mock_request)
|
|
|
|
arguments = collect_tool_arguments(results)
|
|
assert arguments == result.tool_calls[0].function.arguments
|
|
assert json.loads(arguments)["content"] == CONTENT_LINE * 200
|
|
assert ('"mode": 644,' if use_schema else '"mode": "644",') in arguments
|
|
|
|
def test_conversions_do_not_grow_with_argument_length(
|
|
self, mock_tokenizer, mock_request, write_tool
|
|
):
|
|
mock_request.tools = [write_tool]
|
|
base = minimax_m2_config()
|
|
converter = base.arg_converter
|
|
assert converter is not None
|
|
|
|
def streaming_conversions(lines: int) -> int:
|
|
calls = 0
|
|
|
|
def counting_converter(raw_args: str, partial: bool) -> str:
|
|
nonlocal calls
|
|
calls += partial
|
|
return converter(raw_args, partial)
|
|
|
|
parser = ParserEngine(
|
|
mock_tokenizer,
|
|
parser_engine_config=dataclasses.replace(
|
|
base, arg_converter=counting_converter
|
|
),
|
|
)
|
|
text = WRITE_CALL_HEAD + CONTENT_LINE * lines + WRITE_CALL_TAIL
|
|
simulate_tool_streaming(
|
|
parser,
|
|
mock_request,
|
|
[text[i : i + 4] for i in range(0, len(text), 4)],
|
|
)
|
|
return calls
|
|
|
|
assert streaming_conversions(lines=20) == streaming_conversions(lines=200)
|
|
|
|
def test_extender_stops_at_split_end_tag(self):
|
|
extend = _minimax_m2_arg_value_extender('<parameter name="k">')
|
|
assert extend is not None
|
|
deltas = ['a"\n', "</param", "eter>"]
|
|
assert [extend(d) for d in deltas] == ['a\\"\\n', "</param", None]
|
|
|
|
def test_extender_stops_at_split_next_parameter(self):
|
|
extend = _minimax_m2_arg_value_extender("<parameter name='k'>x")
|
|
assert extend is not None
|
|
assert [extend(d) for d in ["< parameter", " name ="]] == ["< parameter", None]
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw_args",
|
|
[
|
|
'<parameter name="k">x</parameter>',
|
|
'<parameter name="k',
|
|
(
|
|
'<parameter name="k">1</parameter><parameter name="j">2</parameter>'
|
|
'<parameter name="k">3'
|
|
),
|
|
'<parameter name="a<parameter name=k>x',
|
|
'<parameter name="k>x',
|
|
],
|
|
ids=[
|
|
"closed_value",
|
|
"open_name",
|
|
"repeated_key_not_last",
|
|
"earlier_unmatched_opener",
|
|
"bare_name_opens_quote",
|
|
],
|
|
)
|
|
def test_no_extender_unless_open_value_is_stable(self, raw_args):
|
|
assert _minimax_m2_arg_value_extender(raw_args) is None
|
|
|
|
|
|
class TestReasoning:
|
|
def test_extract_reasoning_without_start_token(self, parser, mock_request):
|
|
reasoning, content = parser.extract_reasoning(
|
|
"This is reasoning</think>This is content",
|
|
mock_request,
|
|
)
|
|
|
|
assert reasoning == "This is reasoning"
|
|
assert content == "This is content"
|
|
|
|
def test_extract_reasoning_without_end_token(self, parser, mock_request):
|
|
reasoning, content = parser.extract_reasoning(
|
|
"This is still reasoning",
|
|
mock_request,
|
|
)
|
|
|
|
assert reasoning == "This is still reasoning"
|
|
assert content is None
|
|
|
|
def test_extract_content_ids_without_end_token(self, parser):
|
|
assert parser.extract_content_ids([1, 2, 3]) == []
|
|
|
|
def test_extract_content_ids_after_end_token(self, parser):
|
|
assert parser.extract_content_ids([1, 99, 2, 3]) == [2, 3]
|