469 lines
16 KiB
Python
469 lines
16 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from transformers import AutoTokenizer
|
|
|
|
from tests.tool_parsers.utils import (
|
|
run_tool_extraction,
|
|
run_tool_extraction_streaming,
|
|
)
|
|
from vllm.entrypoints.openai.engine.protocol import FunctionCall
|
|
from vllm.tokenizers import TokenizerLike
|
|
from vllm.tool_parsers import ToolParser, ToolParserManager
|
|
|
|
TOOL_CALL_START = "<|tool_call_start|>"
|
|
TOOL_CALL_END = "<|tool_call_end|>"
|
|
|
|
SIMPLE_FUNCTION_OUTPUT = "get_candidate_status(candidate_id='12345')"
|
|
SIMPLE_FUNCTION_CALL = FunctionCall(
|
|
name="get_candidate_status",
|
|
arguments='{"candidate_id": "12345"}',
|
|
)
|
|
MORE_TYPES_FUNCTION_OUTPUT = (
|
|
"register_user(name='John Doe', "
|
|
"age=37, "
|
|
"address={'city': 'San Francisco', 'state': 'CA'}, "
|
|
"role=None, "
|
|
"passed_test=True, "
|
|
"aliases=['John', 'Johnny'])"
|
|
)
|
|
MORE_TYPES_FUNCTION_CALL = FunctionCall(
|
|
name="register_user",
|
|
arguments='{"name": "John Doe", '
|
|
'"age": 37, '
|
|
'"address": {"city": "San Francisco", "state": "CA"}, '
|
|
'"role": null, '
|
|
'"passed_test": true, '
|
|
'"aliases": ["John", "Johnny"]}',
|
|
)
|
|
PARAMETERLESS_FUNCTION_OUTPUT = "get_weather()"
|
|
PARAMETERLESS_FUNCTION_CALL = FunctionCall(
|
|
name="get_weather",
|
|
arguments="{}",
|
|
)
|
|
EMPTY_DICT_FUNCTION_OUTPUT = "do_something_cool(additional_data={})"
|
|
EMPTY_DICT_FUNCTION_CALL = FunctionCall(
|
|
name="do_something_cool",
|
|
arguments='{"additional_data": {}}',
|
|
)
|
|
EMPTY_LIST_FUNCTION_OUTPUT = "do_something_cool(steps=[])"
|
|
EMPTY_LIST_FUNCTION_CALL = FunctionCall(
|
|
name="do_something_cool",
|
|
arguments='{"steps": []}',
|
|
)
|
|
ESCAPED_STRING_FUNCTION_OUTPUT = (
|
|
r"get_weather(city='Martha\'s Vineyard', metric='\"cool units\"')"
|
|
)
|
|
ESCAPED_STRING_FUNCTION_CALL = FunctionCall(
|
|
name="get_weather",
|
|
arguments='{"city": "Martha\'s Vineyard", "metric": "\\"cool units\\""}',
|
|
)
|
|
DOTTED_NAME_FUNCTION_OUTPUT = (
|
|
"grocery.orderIngredients("
|
|
"ingredientList=[{'name': 'Lasagna noodles', 'amount': 250, 'unit': 'g'}], "
|
|
"deliveryAddress='845 Willow Lane, Springfield, IL 62704')"
|
|
)
|
|
DOTTED_NAME_FUNCTION_CALL = FunctionCall(
|
|
name="grocery.orderIngredients",
|
|
arguments=(
|
|
'{"ingredientList": ['
|
|
'{"name": "Lasagna noodles", "amount": 250, "unit": "g"}], '
|
|
'"deliveryAddress": "845 Willow Lane, Springfield, IL 62704"}'
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def lfm2_tokenizer() -> TokenizerLike:
|
|
return AutoTokenizer.from_pretrained("LiquidAI/LFM2.5-1.2B-Instruct")
|
|
|
|
|
|
def _wrap(tool_text: str, content_after: str = "") -> str:
|
|
"""Wrap pythonic tool call in LFM2.5 sentinel tokens."""
|
|
result = f"{TOOL_CALL_START}[{tool_text}]{TOOL_CALL_END}"
|
|
if content_after:
|
|
result += f"\n{content_after}"
|
|
return result
|
|
|
|
|
|
@pytest.mark.parametrize("streaming", [True, False])
|
|
def test_no_tool_call(streaming: bool, lfm2_tokenizer: TokenizerLike):
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
model_output = "How can I help you today?"
|
|
|
|
content, tool_calls = run_tool_extraction(
|
|
tool_parser, model_output, streaming=streaming
|
|
)
|
|
|
|
assert content == model_output
|
|
assert len(tool_calls) == 0
|
|
|
|
|
|
TEST_CASES = [
|
|
pytest.param(
|
|
True,
|
|
_wrap(SIMPLE_FUNCTION_OUTPUT),
|
|
[SIMPLE_FUNCTION_CALL],
|
|
None,
|
|
id="simple_streaming",
|
|
),
|
|
pytest.param(
|
|
False,
|
|
_wrap(SIMPLE_FUNCTION_OUTPUT),
|
|
[SIMPLE_FUNCTION_CALL],
|
|
None,
|
|
id="simple_nonstreaming",
|
|
),
|
|
pytest.param(
|
|
True,
|
|
_wrap(MORE_TYPES_FUNCTION_OUTPUT),
|
|
[MORE_TYPES_FUNCTION_CALL],
|
|
None,
|
|
id="more_types_streaming",
|
|
),
|
|
pytest.param(
|
|
False,
|
|
_wrap(MORE_TYPES_FUNCTION_OUTPUT),
|
|
[MORE_TYPES_FUNCTION_CALL],
|
|
None,
|
|
id="more_types_nonstreaming",
|
|
),
|
|
pytest.param(
|
|
True,
|
|
_wrap(PARAMETERLESS_FUNCTION_OUTPUT),
|
|
[PARAMETERLESS_FUNCTION_CALL],
|
|
None,
|
|
id="parameterless_streaming",
|
|
),
|
|
pytest.param(
|
|
False,
|
|
_wrap(PARAMETERLESS_FUNCTION_OUTPUT),
|
|
[PARAMETERLESS_FUNCTION_CALL],
|
|
None,
|
|
id="parameterless_nonstreaming",
|
|
),
|
|
pytest.param(
|
|
True,
|
|
_wrap(EMPTY_DICT_FUNCTION_OUTPUT),
|
|
[EMPTY_DICT_FUNCTION_CALL],
|
|
None,
|
|
id="empty_dict_streaming",
|
|
),
|
|
pytest.param(
|
|
False,
|
|
_wrap(EMPTY_DICT_FUNCTION_OUTPUT),
|
|
[EMPTY_DICT_FUNCTION_CALL],
|
|
None,
|
|
id="empty_dict_nonstreaming",
|
|
),
|
|
pytest.param(
|
|
True,
|
|
_wrap(EMPTY_LIST_FUNCTION_OUTPUT),
|
|
[EMPTY_LIST_FUNCTION_CALL],
|
|
None,
|
|
id="empty_list_streaming",
|
|
),
|
|
pytest.param(
|
|
False,
|
|
_wrap(EMPTY_LIST_FUNCTION_OUTPUT),
|
|
[EMPTY_LIST_FUNCTION_CALL],
|
|
None,
|
|
id="empty_list_nonstreaming",
|
|
),
|
|
pytest.param(
|
|
True,
|
|
_wrap(ESCAPED_STRING_FUNCTION_OUTPUT),
|
|
[ESCAPED_STRING_FUNCTION_CALL],
|
|
None,
|
|
id="escaped_string_streaming",
|
|
),
|
|
pytest.param(
|
|
False,
|
|
_wrap(ESCAPED_STRING_FUNCTION_OUTPUT),
|
|
[ESCAPED_STRING_FUNCTION_CALL],
|
|
None,
|
|
id="escaped_string_nonstreaming",
|
|
),
|
|
pytest.param(
|
|
True,
|
|
_wrap(f"{SIMPLE_FUNCTION_OUTPUT}, {MORE_TYPES_FUNCTION_OUTPUT}"),
|
|
[SIMPLE_FUNCTION_CALL, MORE_TYPES_FUNCTION_CALL],
|
|
None,
|
|
id="parallel_calls_streaming",
|
|
),
|
|
pytest.param(
|
|
False,
|
|
_wrap(f"{SIMPLE_FUNCTION_OUTPUT}, {MORE_TYPES_FUNCTION_OUTPUT}"),
|
|
[SIMPLE_FUNCTION_CALL, MORE_TYPES_FUNCTION_CALL],
|
|
None,
|
|
id="parallel_calls_nonstreaming",
|
|
),
|
|
# LFM2.5 specific: content AFTER tool call
|
|
pytest.param(
|
|
False,
|
|
_wrap(
|
|
SIMPLE_FUNCTION_OUTPUT,
|
|
content_after="Checking the current status of candidate ID 12345.",
|
|
),
|
|
[SIMPLE_FUNCTION_CALL],
|
|
"Checking the current status of candidate ID 12345.",
|
|
id="content_after_tool_call_nonstreaming",
|
|
),
|
|
# Dotted / class-method function names: grocery.orderIngredients(...)
|
|
pytest.param(
|
|
True,
|
|
_wrap(DOTTED_NAME_FUNCTION_OUTPUT),
|
|
[DOTTED_NAME_FUNCTION_CALL],
|
|
None,
|
|
id="dotted_name_streaming",
|
|
),
|
|
pytest.param(
|
|
False,
|
|
_wrap(DOTTED_NAME_FUNCTION_OUTPUT),
|
|
[DOTTED_NAME_FUNCTION_CALL],
|
|
None,
|
|
id="dotted_name_nonstreaming",
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"streaming, model_output, expected_tool_calls, expected_content",
|
|
TEST_CASES,
|
|
)
|
|
def test_tool_call(
|
|
streaming: bool,
|
|
model_output: str,
|
|
expected_tool_calls: list[FunctionCall],
|
|
expected_content: str | None,
|
|
lfm2_tokenizer: TokenizerLike,
|
|
):
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
|
|
content, tool_calls = run_tool_extraction(
|
|
tool_parser, model_output, streaming=streaming
|
|
)
|
|
|
|
if expected_content and not streaming:
|
|
assert content == expected_content
|
|
assert len(tool_calls) == len(expected_tool_calls)
|
|
for actual, expected in zip(tool_calls, expected_tool_calls):
|
|
assert actual.type == "function"
|
|
assert actual.function == expected
|
|
|
|
|
|
def test_streaming_tool_call_with_large_steps(lfm2_tokenizer: TokenizerLike):
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
model_output_deltas = [
|
|
f"{TOOL_CALL_START}[get_candidate_status(candidate_id='12345'), "
|
|
f"{PARAMETERLESS_FUNCTION_OUTPUT}, "
|
|
f"{EMPTY_LIST_FUNCTION_OUTPUT}]{TOOL_CALL_END}",
|
|
]
|
|
|
|
reconstructor = run_tool_extraction_streaming(
|
|
tool_parser, model_output_deltas, assert_one_tool_per_delta=False
|
|
)
|
|
|
|
assert len(reconstructor.tool_calls) == 3
|
|
assert reconstructor.tool_calls[0].function == SIMPLE_FUNCTION_CALL
|
|
assert reconstructor.tool_calls[1].function == PARAMETERLESS_FUNCTION_CALL
|
|
assert reconstructor.tool_calls[2].function == EMPTY_LIST_FUNCTION_CALL
|
|
|
|
|
|
def test_streaming_full_block_and_trailing_in_single_delta(
|
|
lfm2_tokenizer: TokenizerLike,
|
|
):
|
|
"""The entire <|tool_call_start|>[...]<|tool_call_end|> block plus
|
|
trailing assistant text arrive in one delta. Trailing content must
|
|
still be emitted — not silently dropped."""
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
full_text = f"{TOOL_CALL_START}[{SIMPLE_FUNCTION_OUTPUT}]{TOOL_CALL_END}\nDone."
|
|
|
|
reconstructor = run_tool_extraction_streaming(tool_parser, [full_text])
|
|
|
|
assert len(reconstructor.tool_calls) == 1
|
|
assert reconstructor.tool_calls[0].function == SIMPLE_FUNCTION_CALL
|
|
assert "Done." in reconstructor.other_content
|
|
|
|
|
|
def test_streaming_leading_content_and_full_block_in_single_delta(
|
|
lfm2_tokenizer: TokenizerLike,
|
|
):
|
|
"""Leading assistant text plus the entire tool block arrive in one
|
|
delta. Leading content must be emitted — not silently dropped."""
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
full_text = (
|
|
f"Let me check. {TOOL_CALL_START}[{SIMPLE_FUNCTION_OUTPUT}]{TOOL_CALL_END}"
|
|
)
|
|
|
|
reconstructor = run_tool_extraction_streaming(tool_parser, [full_text])
|
|
|
|
assert len(reconstructor.tool_calls) == 1
|
|
assert reconstructor.tool_calls[0].function == SIMPLE_FUNCTION_CALL
|
|
assert "Let me check." in reconstructor.other_content
|
|
|
|
|
|
def test_streaming_leading_block_and_trailing_in_single_delta(
|
|
lfm2_tokenizer: TokenizerLike,
|
|
):
|
|
"""Leading text + complete tool block + trailing text in one delta.
|
|
Both leading and trailing content must be preserved."""
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
full_text = (
|
|
"Let me check. "
|
|
f"{TOOL_CALL_START}[{SIMPLE_FUNCTION_OUTPUT}]{TOOL_CALL_END}\nDone."
|
|
)
|
|
|
|
reconstructor = run_tool_extraction_streaming(tool_parser, [full_text])
|
|
|
|
assert len(reconstructor.tool_calls) == 1
|
|
assert reconstructor.tool_calls[0].function == SIMPLE_FUNCTION_CALL
|
|
assert "Let me check." in reconstructor.other_content
|
|
assert "Done." in reconstructor.other_content
|
|
|
|
|
|
def test_echoed_tool_call_body_not_leaked_to_content(
|
|
lfm2_tokenizer: TokenizerLike,
|
|
):
|
|
"""LFM2 sometimes emits the tool call body again after the first
|
|
<|tool_call_end|>, capped with a second <|tool_call_end|>. The
|
|
echoed body must not surface as assistant content — neither in
|
|
streaming nor non-streaming paths."""
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
body = (
|
|
"[grocery.orderIngredients("
|
|
"ingredientList=[{'name': 'apple', 'quantity': '2'}], "
|
|
"deliveryAddress='123 Main St')]"
|
|
)
|
|
model_output = f"{TOOL_CALL_START}{body}{TOOL_CALL_END}{body}{TOOL_CALL_END}"
|
|
|
|
# Non-streaming
|
|
content_ns, tool_calls_ns = run_tool_extraction(
|
|
tool_parser, model_output, streaming=False
|
|
)
|
|
assert len(tool_calls_ns) == 1
|
|
assert tool_calls_ns[0].function.name == "grocery.orderIngredients"
|
|
assert content_ns in (None, "")
|
|
|
|
# Streaming: re-fetch a fresh parser since state was mutated above.
|
|
tool_parser2: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
content_s, tool_calls_s = run_tool_extraction(
|
|
tool_parser2, model_output, streaming=True
|
|
)
|
|
assert len(tool_calls_s) == 1
|
|
assert tool_calls_s[0].function.name == "grocery.orderIngredients"
|
|
# Echoed body must not leak as content.
|
|
assert content_s in (None, "")
|
|
assert "grocery.orderIngredients" not in (content_s or "")
|
|
assert TOOL_CALL_END not in (content_s or "")
|
|
|
|
|
|
def test_streaming_char_by_char_multi_dict_list(lfm2_tokenizer: TokenizerLike):
|
|
"""Stream a tool call containing a list of multiple dicts one
|
|
character at a time. Every prefix lands in some partial-parse state
|
|
(mid-key, mid-value, open quote inside dict, empty dict, etc.). The
|
|
parser must not raise — incomplete prefixes should silently wait for
|
|
more text instead of logging exceptions."""
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
full_text = (
|
|
f"{TOOL_CALL_START}[grocery.orderIngredients("
|
|
"ingredientList=["
|
|
'{"name": "apple", "quantity": "2"}, '
|
|
'{"name": "bread", "quantity": "1"}'
|
|
f"])]{TOOL_CALL_END}"
|
|
)
|
|
deltas = [c for c in full_text]
|
|
|
|
reconstructor = run_tool_extraction_streaming(
|
|
tool_parser, deltas, assert_one_tool_per_delta=False
|
|
)
|
|
|
|
assert len(reconstructor.tool_calls) == 1
|
|
assert reconstructor.tool_calls[0].function.name == "grocery.orderIngredients"
|
|
import json
|
|
|
|
args = json.loads(reconstructor.tool_calls[0].function.arguments)
|
|
assert args == {
|
|
"ingredientList": [
|
|
{"name": "apple", "quantity": "2"},
|
|
{"name": "bread", "quantity": "1"},
|
|
]
|
|
}
|
|
|
|
|
|
def test_streaming_dotted_name_in_single_delta(lfm2_tokenizer: TokenizerLike):
|
|
"""A pythonic call with a dotted/attribute function name (e.g.
|
|
``domain.method(arg=...)``) must be parsed correctly in streaming mode
|
|
just as in non-streaming mode."""
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
full_text = f"{TOOL_CALL_START}[{DOTTED_NAME_FUNCTION_OUTPUT}]{TOOL_CALL_END}"
|
|
|
|
reconstructor = run_tool_extraction_streaming(tool_parser, [full_text])
|
|
|
|
assert len(reconstructor.tool_calls) == 1
|
|
assert reconstructor.tool_calls[0].function == DOTTED_NAME_FUNCTION_CALL
|
|
|
|
|
|
def test_adjust_request_disables_skip_special_tokens(
|
|
lfm2_tokenizer: TokenizerLike,
|
|
):
|
|
"""When tools are present, the parser must force
|
|
``skip_special_tokens=False`` so the engine does not strip the
|
|
<|tool_call_start|>/<|tool_call_end|> sentinels before they reach the
|
|
parser."""
|
|
from vllm.entrypoints.openai.chat_completion.protocol import (
|
|
ChatCompletionRequest,
|
|
)
|
|
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
|
|
request_with_tools = ChatCompletionRequest(
|
|
messages=[],
|
|
model="test-model",
|
|
tools=[
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"city": {"type": "string"}},
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
assert request_with_tools.skip_special_tokens is True
|
|
adjusted = tool_parser.adjust_request(request_with_tools)
|
|
assert adjusted.skip_special_tokens is False
|
|
|
|
# No tools → no override; default behaviour preserved.
|
|
request_no_tools = ChatCompletionRequest(messages=[], model="test-model")
|
|
assert request_no_tools.skip_special_tokens is True
|
|
adjusted_no_tools = tool_parser.adjust_request(request_no_tools)
|
|
assert adjusted_no_tools.skip_special_tokens is True
|
|
|
|
|
|
@pytest.mark.parametrize("streaming", [False])
|
|
def test_regex_timeout_handling(streaming: bool, lfm2_tokenizer: TokenizerLike):
|
|
"""Test regex timeout is handled gracefully."""
|
|
tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
|
|
|
|
fake_input = f"{TOOL_CALL_START}[A(A=" + "\t)A(A=,\t" * 2
|
|
fake_input += f"]{TOOL_CALL_END}"
|
|
|
|
mock_regex = MagicMock()
|
|
mock_regex.match.side_effect = TimeoutError("Regex timeout")
|
|
|
|
with patch.object(tool_parser, "TOOL_CALL_REGEX", mock_regex):
|
|
content, tool_calls = run_tool_extraction(
|
|
tool_parser, fake_input, streaming=streaming
|
|
)
|
|
|
|
assert content == fake_input
|
|
assert len(tool_calls) == 0
|
|
mock_regex.match.assert_called_once()
|