Signed-off-by: tc-mb <tianchi_cai@icloud.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
460 lines
15 KiB
Python
460 lines
15 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Tests for the MiniCPM-V family parser."""
|
|
|
|
import pytest
|
|
|
|
from tests.parser.engine.conftest import make_mock_tokenizer
|
|
from tests.parser.engine.streaming_helpers import simulate_reasoning_streaming
|
|
from vllm.entrypoints.generate.base.protocol import (
|
|
DeltaFunctionCall,
|
|
DeltaMessage,
|
|
DeltaToolCall,
|
|
)
|
|
from vllm.entrypoints.openai.chat_completion.protocol import (
|
|
ChatCompletionRequest,
|
|
ChatCompletionToolsParam,
|
|
)
|
|
from vllm.parser.minicpmv import (
|
|
EscapedNewlineNormalizer,
|
|
MiniCPMVOutputNormalizer,
|
|
recover_newlines,
|
|
)
|
|
from vllm.parser.parser_manager import ParserManager
|
|
from vllm.reasoning import ReasoningParserManager
|
|
from vllm.tool_parsers import ToolParserManager
|
|
|
|
_VOCAB = {
|
|
"<think>": 10,
|
|
"</think>": 11,
|
|
"<|im_end|>": 12,
|
|
"<tool_call>": 13,
|
|
"</tool_call>": 14,
|
|
}
|
|
|
|
_TOOLS = [
|
|
ChatCompletionToolsParam(
|
|
type="function",
|
|
function={
|
|
"name": "get_weather",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"city": {"type": "string"}},
|
|
},
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_tokenizer():
|
|
return make_mock_tokenizer(_VOCAB)
|
|
|
|
|
|
@pytest.fixture
|
|
def request_obj():
|
|
return ChatCompletionRequest(
|
|
model="test-model",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
)
|
|
|
|
|
|
def _make_parser(tokenizer, *, enable_thinking=None, tools=None):
|
|
"""Build the parser the serving stack resolves for MiniCPM-V.
|
|
|
|
Everything is built through ``ParserManager`` so the tests exercise the
|
|
``DelegatingParser`` the server actually drives, not the parser engine on
|
|
its own. Tool calling goes through ``qwen3_coder``: MiniCPM-V emits that
|
|
XML syntax verbatim and has no tool parser of its own.
|
|
"""
|
|
parser_cls = ParserManager.get_parser(
|
|
tool_parser_name="qwen3_coder" if tools else None,
|
|
reasoning_parser_name="minicpmv",
|
|
enable_auto_tools=bool(tools),
|
|
)
|
|
assert parser_cls is not None
|
|
kwargs = {}
|
|
if enable_thinking is not None:
|
|
kwargs["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
|
|
return parser_cls(tokenizer, tools, **kwargs)
|
|
|
|
|
|
def test_reasoning_parser_is_registered():
|
|
parser_cls = ReasoningParserManager.get_reasoning_parser("minicpmv")
|
|
|
|
assert parser_cls.__name__ == "MiniCPMVParserReasoningAdapter"
|
|
|
|
|
|
def test_thinking_defaults_to_the_chat_template(mock_tokenizer):
|
|
"""Without `enable_thinking`, the grammar must wait for reasoning.
|
|
|
|
The chat template ends the assistant turn with `<think>` unless the flag is
|
|
explicitly false, so the model reasons by default. This is the flag
|
|
`serving.py` latches into the structured-output gate, so a non-thinking
|
|
default both disables the grammar and leaks the thinking into `content`.
|
|
"""
|
|
|
|
def reasoning_pending(**kwargs):
|
|
return not _make_parser(mock_tokenizer, **kwargs).is_reasoning_end([1, 2])
|
|
|
|
assert reasoning_pending() is True # default matches the template
|
|
assert reasoning_pending(enable_thinking=True) is True
|
|
assert reasoning_pending(enable_thinking=False) is False
|
|
|
|
|
|
def test_no_minicpmv_tool_parser_is_registered():
|
|
"""MiniCPM-V deliberately registers no tool parser.
|
|
|
|
The family emits the Qwen3-Coder XML syntax verbatim, so
|
|
``--tool-call-parser qwen3_coder`` already covers it. Registering a
|
|
``minicpmv`` tool parser would duplicate that name for no parsing gain.
|
|
"""
|
|
with pytest.raises(KeyError):
|
|
ToolParserManager.get_tool_parser("minicpmv")
|
|
|
|
|
|
def test_composes_with_qwen3_coder_tool_parser(mock_tokenizer):
|
|
"""`--tool-call-parser qwen3_coder` must work through the serving Parser."""
|
|
request = ChatCompletionRequest(
|
|
model="test-model",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=_TOOLS,
|
|
)
|
|
parser_cls = ParserManager.get_parser(
|
|
tool_parser_name="qwen3_coder",
|
|
reasoning_parser_name="minicpmv",
|
|
enable_auto_tools=True,
|
|
)
|
|
assert parser_cls is not None
|
|
parser = parser_cls(mock_tokenizer, request.tools)
|
|
|
|
_, _, tool_calls = parser.parse(
|
|
"<tool_call><function=get_weather>"
|
|
"<parameter=city>SF</parameter></function></tool_call>",
|
|
request,
|
|
enable_auto_tools=True,
|
|
)
|
|
|
|
assert tool_calls is not None
|
|
assert tool_calls[0].name == "get_weather"
|
|
assert "SF" in tool_calls[0].arguments
|
|
|
|
|
|
class TestNonThinking:
|
|
@pytest.fixture
|
|
def parser(self, mock_tokenizer):
|
|
return _make_parser(mock_tokenizer, enable_thinking=False)
|
|
|
|
def test_plain_content_is_unchanged(self, parser):
|
|
reasoning, content = parser.extract_reasoning("The answer is 42.", None)
|
|
|
|
assert reasoning is None
|
|
assert content == "The answer is 42."
|
|
|
|
def test_reserved_markers_are_removed(self, parser):
|
|
output = (
|
|
"<reserved_12>reasoning<reserved_13><|reserved_14|>tool call<|reserved_15|>"
|
|
)
|
|
|
|
reasoning, content = parser.extract_reasoning(output, None)
|
|
|
|
assert reasoning is None
|
|
assert content == "reasoningtool call"
|
|
|
|
def test_unrelated_reserved_text_is_unchanged(self, parser):
|
|
reasoning, content = parser.extract_reasoning(
|
|
"<reserved_11>content<|reserved_16|>",
|
|
None,
|
|
)
|
|
|
|
assert reasoning is None
|
|
assert content == "<reserved_11>content<|reserved_16|>"
|
|
|
|
def test_dangling_standard_think_end_is_removed(self, parser):
|
|
output = "first part </think>\n\nfinal part"
|
|
|
|
reasoning, content = parser.extract_reasoning(output, None)
|
|
|
|
assert reasoning is None
|
|
assert content == "first part \n\nfinal part"
|
|
|
|
def test_truncated_standard_reasoning_does_not_leak(self, parser):
|
|
reasoning, content = parser.extract_reasoning(
|
|
"prefix<think>private reasoning",
|
|
None,
|
|
)
|
|
|
|
assert reasoning is None
|
|
assert content == "prefix"
|
|
|
|
def test_streaming_split_standard_markers(self, parser):
|
|
reasoning, content = simulate_reasoning_streaming(
|
|
parser,
|
|
[
|
|
"<",
|
|
"think>",
|
|
"private reasoning",
|
|
"</",
|
|
"think>",
|
|
"final answer",
|
|
],
|
|
)
|
|
|
|
assert reasoning == ""
|
|
assert content == "final answer"
|
|
|
|
def test_streaming_split_reserved_markers(self, parser):
|
|
reasoning, content = simulate_reasoning_streaming(
|
|
parser,
|
|
[
|
|
"<reser",
|
|
"ved_12>",
|
|
"reasoning",
|
|
"<|reserved",
|
|
"_13|>",
|
|
"final answer",
|
|
],
|
|
)
|
|
|
|
assert reasoning == ""
|
|
assert content == "reasoningfinal answer"
|
|
|
|
def test_tool_calls_are_parsed(self, mock_tokenizer):
|
|
"""Thinking is off by default, and tool calls must still be parsed."""
|
|
parser = _make_parser(mock_tokenizer, enable_thinking=False, tools=_TOOLS)
|
|
request = ChatCompletionRequest(
|
|
model="test-model",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=_TOOLS,
|
|
)
|
|
|
|
reasoning, content, tool_calls = parser.parse(
|
|
"<tool_call><function=get_weather>"
|
|
"<parameter=city>SF</parameter></function></tool_call>",
|
|
request,
|
|
enable_auto_tools=True,
|
|
)
|
|
|
|
assert reasoning is None
|
|
assert not content
|
|
assert tool_calls is not None
|
|
assert tool_calls[0].name == "get_weather"
|
|
assert "SF" in tool_calls[0].arguments
|
|
|
|
def test_streaming_tool_calls_are_parsed(self, mock_tokenizer):
|
|
"""Regression: tool calls must stream when thinking is explicitly off.
|
|
|
|
The reasoning parser used to report ``reasoning_ended=False`` in
|
|
non-thinking mode, which kept the delegating parser in its reasoning
|
|
phase and streamed the raw ``<tool_call>`` XML to the client as content.
|
|
"""
|
|
parser = _make_parser(mock_tokenizer, enable_thinking=False, tools=_TOOLS)
|
|
request = ChatCompletionRequest(
|
|
model="test-model",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=_TOOLS,
|
|
)
|
|
|
|
delta = parser.parse_delta(
|
|
"<tool_call><function=get_weather>"
|
|
"<parameter=city>SF</parameter></function></tool_call>",
|
|
[],
|
|
request,
|
|
prompt_token_ids=[],
|
|
finished=True,
|
|
)
|
|
|
|
assert delta is not None
|
|
assert delta.tool_calls
|
|
assert delta.tool_calls[0].function is not None
|
|
assert delta.tool_calls[0].function.name == "get_weather"
|
|
assert "SF" in (delta.tool_calls[0].function.arguments or "")
|
|
assert not delta.content
|
|
|
|
|
|
class TestThinking:
|
|
def test_standard_think_tags_are_parsed(self, mock_tokenizer):
|
|
parser = _make_parser(mock_tokenizer, enable_thinking=True)
|
|
|
|
reasoning, content = parser.extract_reasoning(
|
|
"<think>private reasoning</think>final answer",
|
|
None,
|
|
)
|
|
|
|
assert reasoning == "private reasoning"
|
|
assert content == "final answer"
|
|
|
|
def test_reserved_markers_are_removed(self, mock_tokenizer):
|
|
parser = _make_parser(mock_tokenizer, enable_thinking=True)
|
|
|
|
reasoning, content = parser.extract_reasoning(
|
|
"<think><reserved_12>private reasoning</think><|reserved_13|>final answer",
|
|
None,
|
|
)
|
|
|
|
assert reasoning == "private reasoning"
|
|
assert content == "final answer"
|
|
|
|
def test_streaming_im_end_is_removed(self):
|
|
tokenizer = make_mock_tokenizer(_VOCAB, special_tokens=[])
|
|
parser = _make_parser(tokenizer, enable_thinking=True)
|
|
|
|
reasoning, content = simulate_reasoning_streaming(
|
|
parser,
|
|
["Answer: 111", "<|im_end|>"],
|
|
)
|
|
|
|
assert reasoning == "Answer: 111"
|
|
assert content == ""
|
|
|
|
|
|
class TestNewlineRecovery:
|
|
"""The tokenizer writes a newline as the two characters ``\\`` and ``n``."""
|
|
|
|
def test_recover_newlines(self):
|
|
text = (
|
|
"first\\nsecond\\rthird\\r\\nfourth "
|
|
"`code\\nvalue` "
|
|
"```python\\nvalue = '\\\\n'\\n``` "
|
|
"$x\\ny$ $$x\\ny$$ \\(x\\ny\\) \\[x\\ny\\] "
|
|
"escaped \\\\n"
|
|
)
|
|
|
|
assert recover_newlines(text) == (
|
|
"first\nsecond\nthird\nfourth "
|
|
"`code\\nvalue` "
|
|
"```python\\nvalue = '\\\\n'\\n``` "
|
|
"$x\\ny$ $$x\\ny$$ \\(x\\ny\\) \\[x\\ny\\] "
|
|
"escaped \\\\n"
|
|
)
|
|
|
|
def test_streaming_matches_complete_recovery(self):
|
|
text = (
|
|
"first\\nsecond `code\\nvalue` $x\\ny$ ```text\\nvalue\\n``` last\\r\\nline"
|
|
)
|
|
expected = recover_newlines(text)
|
|
|
|
for split in range(len(text) + 1):
|
|
normalizer = EscapedNewlineNormalizer()
|
|
actual = normalizer.feed(text[:split])
|
|
actual += normalizer.feed(text[split:], final=True)
|
|
assert actual == expected
|
|
|
|
normalizer = EscapedNewlineNormalizer()
|
|
actual = "".join(normalizer.feed(char) for char in text)
|
|
actual += normalizer.feed("", final=True)
|
|
assert actual == expected
|
|
|
|
def test_backslash_runs_stay_consistent_across_splits(self):
|
|
# A run of backslashes can be split over chunks. Its parity decides
|
|
# whether a following `n` is a newline, so streaming and non-streaming
|
|
# must agree for every split point.
|
|
for count in range(1, 7):
|
|
text = "\\" * count + "n"
|
|
expected = recover_newlines(text)
|
|
|
|
for split in range(len(text) + 1):
|
|
normalizer = EscapedNewlineNormalizer()
|
|
actual = normalizer.feed(text[:split])
|
|
actual += normalizer.feed(text[split:], final=True)
|
|
assert actual == expected, (count, split)
|
|
|
|
def test_streaming_recovers_reasoning_and_content(self):
|
|
normalizer = MiniCPMVOutputNormalizer()
|
|
|
|
first = normalizer.normalize_delta(
|
|
DeltaMessage(reasoning="reason\\"),
|
|
finished=False,
|
|
)
|
|
second = normalizer.normalize_delta(
|
|
DeltaMessage(reasoning="nnext\\", content="answer\\"),
|
|
finished=False,
|
|
)
|
|
final = normalizer.normalize_delta(
|
|
DeltaMessage(content="nend"),
|
|
finished=True,
|
|
)
|
|
|
|
assert first == DeltaMessage(reasoning="reason")
|
|
assert second == DeltaMessage(reasoning="\nnext\\", content="answer")
|
|
assert final == DeltaMessage(content="\nend")
|
|
|
|
def test_streaming_does_not_modify_tool_arguments(self):
|
|
normalizer = MiniCPMVOutputNormalizer()
|
|
arguments = '{"value":"first\\\\nsecond"}'
|
|
delta = DeltaMessage(
|
|
tool_calls=[
|
|
DeltaToolCall(
|
|
index=0,
|
|
function=DeltaFunctionCall(arguments=arguments),
|
|
)
|
|
]
|
|
)
|
|
|
|
result = normalizer.normalize_delta(delta, finished=True)
|
|
|
|
assert result is not None
|
|
assert result.tool_calls[0].function is not None
|
|
assert result.tool_calls[0].function.arguments == arguments
|
|
|
|
def test_parse_recovers_content(self, mock_tokenizer, request_obj):
|
|
parser = _make_parser(mock_tokenizer, enable_thinking=False)
|
|
|
|
reasoning, content, tool_calls = parser.parse(
|
|
"first\\nsecond",
|
|
request_obj,
|
|
)
|
|
|
|
assert reasoning is None
|
|
assert content == "first\nsecond"
|
|
assert not tool_calls
|
|
|
|
def test_parse_delta_recovers_content(self, mock_tokenizer, request_obj):
|
|
parser = _make_parser(mock_tokenizer, enable_thinking=False)
|
|
|
|
chunk = parser.parse_delta(
|
|
"first\\",
|
|
[],
|
|
request_obj,
|
|
finished=False,
|
|
)
|
|
rest = parser.parse_delta(
|
|
"nsecond",
|
|
[],
|
|
request_obj,
|
|
finished=True,
|
|
)
|
|
|
|
# The trailing half of the escape is held back until it is complete.
|
|
assert chunk is not None
|
|
assert chunk.content == "first"
|
|
assert rest is not None
|
|
assert rest.content == "\nsecond"
|
|
|
|
def test_buffered_delta_is_dropped_not_sent_empty(
|
|
self, mock_tokenizer, request_obj
|
|
):
|
|
parser = _make_parser(mock_tokenizer, enable_thinking=False)
|
|
parser.parse_delta("hello", [], request_obj, finished=False)
|
|
|
|
# The lone backtick is held back for the next chunk. Reporting it as an
|
|
# all-None DeltaMessage would reach the client as an empty SSE chunk.
|
|
assert parser.parse_delta("`", [], request_obj, finished=False) is None
|
|
|
|
def test_normalizer_drops_empty_delta(self):
|
|
normalizer = MiniCPMVOutputNormalizer()
|
|
|
|
assert (
|
|
normalizer.normalize_delta(DeltaMessage(content="`"), finished=False)
|
|
is None
|
|
)
|
|
|
|
def test_normalizer_flushes_buffered_content_when_finished(self):
|
|
normalizer = MiniCPMVOutputNormalizer()
|
|
normalizer.normalize_delta(DeltaMessage(content="`"), finished=False)
|
|
|
|
final = normalizer.normalize_delta(DeltaMessage(), finished=True)
|
|
|
|
assert final is not None
|
|
assert final.content == "`"
|