434 lines
16 KiB
Python
434 lines
16 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
import json
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
|
|
from vllm.tool_parsers.apertus_tool_parser import (
|
|
TOOL_CALLS_PREFIX,
|
|
TOOL_CALLS_SUFFIX,
|
|
ApertusToolParser,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_tokenizer():
|
|
tokenizer = MagicMock()
|
|
tokenizer.encode.return_value = [1, 2, 3]
|
|
# Include the tool call tokens in the vocab for the parser
|
|
tokenizer.get_vocab.return_value = {TOOL_CALLS_PREFIX: 100, TOOL_CALLS_SUFFIX: 101}
|
|
return tokenizer
|
|
|
|
|
|
@pytest.fixture
|
|
def parser(mock_tokenizer):
|
|
return ApertusToolParser(mock_tokenizer)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_request():
|
|
request = MagicMock(spec=ChatCompletionRequest)
|
|
request.tools = []
|
|
request.tool_choice = "auto"
|
|
return request
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Non-streaming extraction tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExtractToolCalls:
|
|
def test_no_tool_calls(self, parser, mock_request):
|
|
model_output = "Hello, how can I help you today?"
|
|
result = parser.extract_tool_calls(model_output, mock_request)
|
|
|
|
assert result.tools_called is False
|
|
assert result.tool_calls == []
|
|
assert result.content == model_output
|
|
|
|
def test_single_tool_call(self, parser, mock_request):
|
|
model_output = (
|
|
'<|tools_prefix|>[{"get_weather": {"location": "London"}}]<|tools_suffix|>'
|
|
)
|
|
result = parser.extract_tool_calls(model_output, 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 == {"location": "London"}
|
|
|
|
def test_multiple_arguments(self, parser, mock_request):
|
|
model_output = (
|
|
'<|tools_prefix|>[{"get_weather": '
|
|
'{"location": "San Francisco", '
|
|
'"unit": "celsius"}}]<|tools_suffix|>'
|
|
)
|
|
result = parser.extract_tool_calls(model_output, 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 == {"location": "San Francisco", "unit": "celsius"}
|
|
|
|
def test_text_before_tool_call(self, parser, mock_request):
|
|
model_output = (
|
|
"Let me check the weather for you. "
|
|
'<|tools_prefix|>[{"get_weather": {"location": "Paris"}}]<|tools_suffix|>'
|
|
)
|
|
result = parser.extract_tool_calls(model_output, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
assert result.content == "Let me check the weather for you."
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == "get_weather"
|
|
|
|
def test_multiple_tool_calls(self, parser, mock_request):
|
|
model_output = (
|
|
'<|tools_prefix|>[{"get_weather": '
|
|
'{"location": "London"}}, '
|
|
'{"get_time": {"location": "London"}}]<|tools_suffix|>'
|
|
)
|
|
result = parser.extract_tool_calls(model_output, 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"
|
|
|
|
def test_nested_arguments(self, parser, mock_request):
|
|
model_output = (
|
|
'<|tools_prefix|>[{"complex_function": '
|
|
'{"nested": {"inner": "value"}, '
|
|
'"list": ["a", "b"]}}]<|tools_suffix|>'
|
|
)
|
|
result = parser.extract_tool_calls(model_output, mock_request)
|
|
|
|
assert result.tools_called is True
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].function.name == "complex_function"
|
|
args = json.loads(result.tool_calls[0].function.arguments)
|
|
assert args == {"nested": {"inner": "value"}, "list": ["a", "b"]}
|
|
|
|
def test_incomplete_tool_call(self, parser, mock_request):
|
|
model_output = '<|tools_prefix|>[{"get_weather": {"location": "London"}'
|
|
result = parser.extract_tool_calls(model_output, 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 == {"location": "London"}
|
|
|
|
def test_missing_tool_suffix(self, parser, mock_request):
|
|
model_output = (
|
|
'<|tools_prefix|>[{"get_weather": '
|
|
'{"location": "San Francisco", "unit": "celsius"}}]'
|
|
)
|
|
result = parser.extract_tool_calls(model_output, 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 == {"location": "San Francisco", "unit": "celsius"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Streaming extraction tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStreamingExtraction:
|
|
def _simulate_streaming(
|
|
self, parser: ApertusToolParser, mock_request: Any, chunks: list[str]
|
|
) -> list[tuple[Any, str]]:
|
|
results: list[tuple[Any, str]] = []
|
|
previous_text: str = ""
|
|
previous_token_ids: list[int] = []
|
|
|
|
for chunk in chunks:
|
|
current_text = previous_text + chunk
|
|
# Simulate a token ID sequence matching the chunk progression
|
|
delta_token_ids: list[int] = [0] * max(1, len(chunk) // 4)
|
|
current_token_ids = previous_token_ids + delta_token_ids
|
|
|
|
delta = parser.extract_tool_calls_streaming(
|
|
previous_text=previous_text,
|
|
current_text=current_text,
|
|
delta_text=chunk,
|
|
previous_token_ids=tuple(previous_token_ids),
|
|
current_token_ids=tuple(current_token_ids),
|
|
delta_token_ids=tuple(delta_token_ids),
|
|
request=mock_request,
|
|
)
|
|
results.append((delta, current_text))
|
|
previous_text = current_text
|
|
previous_token_ids = list(current_token_ids)
|
|
|
|
return results
|
|
|
|
def _collect_tool_calls(self, results) -> dict[int, dict[str, Any]]:
|
|
"""Properly tracks and concatenates streamed tool arguments by their Index."""
|
|
tool_calls = {}
|
|
for delta, _ in results:
|
|
if not delta or not getattr(delta, "tool_calls", None):
|
|
continue
|
|
|
|
for tc in delta.tool_calls:
|
|
idx = (
|
|
tc.get("index", 0)
|
|
if isinstance(tc, dict)
|
|
else getattr(tc, "index", 0)
|
|
)
|
|
func = (
|
|
tc.get("function", {})
|
|
if isinstance(tc, dict)
|
|
else getattr(tc, "function", None)
|
|
)
|
|
if not func:
|
|
continue
|
|
|
|
name = (
|
|
func.get("name")
|
|
if isinstance(func, dict)
|
|
else getattr(func, "name", None)
|
|
)
|
|
args = (
|
|
func.get("arguments")
|
|
if isinstance(func, dict)
|
|
else getattr(func, "arguments", None)
|
|
)
|
|
|
|
if idx not in tool_calls:
|
|
tool_calls[idx] = {"name": "", "arguments": ""}
|
|
|
|
if name:
|
|
tool_calls[idx]["name"] += name
|
|
if args:
|
|
tool_calls[idx]["arguments"] += args
|
|
|
|
return tool_calls
|
|
|
|
def _collect_content(self, results) -> str:
|
|
"""Collects generated normal text outside of the tool calls."""
|
|
return "".join(
|
|
delta.content
|
|
for delta, _ in results
|
|
if delta and getattr(delta, "content", None)
|
|
)
|
|
|
|
def test_basic_streaming_single_tool(self, parser, mock_request):
|
|
chunks = [
|
|
"<|tools_prefix|>",
|
|
'[{"get_weather": ',
|
|
'{"location": "Paris, ',
|
|
'France"}}]',
|
|
"<|tools_suffix|>",
|
|
]
|
|
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
tcs = self._collect_tool_calls(results)
|
|
|
|
assert len(tcs) == 1
|
|
assert tcs[0]["name"] == "get_weather"
|
|
assert json.loads(tcs[0]["arguments"]) == {"location": "Paris, France"}
|
|
|
|
def test_streaming_missing_tool_suffix(self, parser, mock_request):
|
|
chunks = [
|
|
"<|tools_prefix|>",
|
|
'[{"get_weather": ',
|
|
'{"location": "Paris, ',
|
|
'France"}}]',
|
|
]
|
|
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
tcs = self._collect_tool_calls(results)
|
|
|
|
assert len(tcs) == 1
|
|
assert tcs[0]["name"] == "get_weather"
|
|
assert json.loads(tcs[0]["arguments"]) == {"location": "Paris, France"}
|
|
|
|
def test_streaming_partial_tag_buffering_missing_tool_suffix(
|
|
self, parser, mock_request
|
|
):
|
|
chunks = ["Content", "<|tools_", "prefix|>", '[{"f": ', '{"a": 1}}]']
|
|
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
content = self._collect_content(results)
|
|
|
|
assert "Content" in content
|
|
assert "<|tools_prefix|>" not in content
|
|
assert "<|tools_suffix|>" not in content
|
|
|
|
tcs = self._collect_tool_calls(results)
|
|
|
|
assert len(tcs) == 1
|
|
assert tcs[0]["name"] == "f"
|
|
assert json.loads(tcs[0]["arguments"]) == {"a": 1}
|
|
|
|
def test_streaming_multi_tool(self, parser, mock_request):
|
|
chunks = [
|
|
"<|tools_prefix|>",
|
|
'[{"get_weather": {"location": "Tokyo"}}',
|
|
', {"get_time": {"location": "Tokyo"}}]',
|
|
"<|tools_suffix|>",
|
|
]
|
|
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
tcs = self._collect_tool_calls(results)
|
|
|
|
assert len(tcs) == 2
|
|
assert tcs[0]["name"] == "get_weather"
|
|
assert json.loads(tcs[0]["arguments"]) == {"location": "Tokyo"}
|
|
assert tcs[1]["name"] == "get_time"
|
|
assert json.loads(tcs[1]["arguments"]) == {"location": "Tokyo"}
|
|
|
|
def test_streaming_text_before_tool_call(self, parser, mock_request):
|
|
chunks = [
|
|
"Let me check ",
|
|
"the weather. ",
|
|
"<|tools_prefix|>",
|
|
'[{"get_weather": {"location": "London"}}]',
|
|
"<|tools_suffix|>",
|
|
]
|
|
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
content = self._collect_content(results)
|
|
|
|
assert content.strip() == "Let me check the weather."
|
|
tcs = self._collect_tool_calls(results)
|
|
|
|
assert len(tcs) == 1
|
|
assert tcs[0]["name"] == "get_weather"
|
|
assert json.loads(tcs[0]["arguments"]) == {"location": "London"}
|
|
|
|
def test_streaming_partial_tag_buffering(self, parser, mock_request):
|
|
chunks = [
|
|
"Content",
|
|
"<|tools_",
|
|
"prefix|>",
|
|
'[{"f": {"a": 1}}]',
|
|
"<|tools_suf",
|
|
"fix|>",
|
|
]
|
|
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
content = self._collect_content(results)
|
|
|
|
assert "Content" in content
|
|
assert "<|tools_prefix|>" not in content
|
|
assert "<|tools_suffix|>" not in content
|
|
|
|
tc = self._collect_tool_calls(results)
|
|
assert len(tc) == 1
|
|
assert tc[0]["name"] == "f"
|
|
assert json.loads(tc[0]["arguments"]) == {"a": 1}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edge Cases: Multi-Token Prediction (MTP) & vLLM Chunking Anomalies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_mtp_streaming_massive_chunk(self, parser, mock_request):
|
|
"""Simulates MTP predicting text, tool calls,
|
|
and trailing text all in a single chunk."""
|
|
chunks = [
|
|
"Sure! "
|
|
'<|tools_prefix|>[{"get_weather": {"location": "London"}}]<|tools_suffix|>'
|
|
]
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
|
|
content = self._collect_content(results)
|
|
assert "Sure! " in content
|
|
|
|
tc = self._collect_tool_calls(results)
|
|
assert len(tc) == 1
|
|
assert tc[0]["name"] == "get_weather"
|
|
assert json.loads(tc[0]["arguments"]) == {"location": "London"}
|
|
|
|
def test_mtp_streaming_multiple_tools_burst(self, parser, mock_request):
|
|
"""Simulates MTP predicting an array of multiple tools in one single chunk."""
|
|
chunks = [
|
|
'<|tools_prefix|>[{"get_weather": '
|
|
'{"location": "London"}}, '
|
|
'{"get_time": {"location": "Paris"}}]<|tools_suffix|>'
|
|
]
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
|
|
tc = self._collect_tool_calls(results)
|
|
assert len(tc) == 2
|
|
assert tc[0]["name"] == "get_weather"
|
|
assert json.loads(tc[0]["arguments"]) == {"location": "London"}
|
|
assert tc[1]["name"] == "get_time"
|
|
assert json.loads(tc[1]["arguments"]) == {"location": "Paris"}
|
|
|
|
def test_mtp_streaming_skip_and_catch_up(self, parser, mock_request):
|
|
"""Simulates MTP chunks that jump over entire tools
|
|
(e.g., from middle of tool 1 to middle of tool 3)."""
|
|
chunks = [
|
|
'<|tools_prefix|>[{"t1": {"a": 1}',
|
|
'}, {"t2": {"b": 2}}, {"t3": {"c": 3',
|
|
"}}]<|tools_suffix|>",
|
|
]
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
|
|
tc = self._collect_tool_calls(results)
|
|
assert len(tc) == 3
|
|
assert tc[0]["name"] == "t1"
|
|
assert json.loads(tc[0]["arguments"]) == {"a": 1}
|
|
assert tc[1]["name"] == "t2"
|
|
assert json.loads(tc[1]["arguments"]) == {"b": 2}
|
|
assert tc[2]["name"] == "t3"
|
|
assert json.loads(tc[2]["arguments"]) == {"c": 3}
|
|
|
|
def test_vllm_streaming_character_by_character(self, parser, mock_request):
|
|
"""Simulates worst-case vLLM fragmentation where
|
|
chunks arrive character-by-character."""
|
|
text = (
|
|
'Hi <|tools_prefix|>[{"get_weather": '
|
|
'{"location": "London"}}]<|tools_suffix|> '
|
|
)
|
|
chunks = list(text)
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
|
|
content = self._collect_content(results)
|
|
assert "Hi" in content
|
|
|
|
tc = self._collect_tool_calls(results)
|
|
assert len(tc) == 1
|
|
assert tc[0]["name"] == "get_weather"
|
|
assert json.loads(tc[0]["arguments"]) == {"location": "London"}
|
|
|
|
def test_vllm_streaming_empty_deltas(self, parser, mock_request):
|
|
"""Simulates vLLM stream producing empty string chunks
|
|
(e.g., hidden tokens or artifacts)."""
|
|
chunks = [
|
|
"Wait",
|
|
"",
|
|
"<|tools_prefix|>",
|
|
"",
|
|
'[{"get_weather": ',
|
|
"",
|
|
'{"location": "London"}}]',
|
|
"<|tools_suffix|>",
|
|
]
|
|
results = self._simulate_streaming(parser, mock_request, chunks)
|
|
|
|
content = self._collect_content(results)
|
|
assert content == "Wait"
|
|
|
|
tc = self._collect_tool_calls(results)
|
|
assert len(tc) == 1
|
|
assert tc[0]["name"] == "get_weather"
|
|
assert json.loads(tc[0]["arguments"]) == {"location": "London"}
|