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

304 lines
10 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for the engine-based Nemotron V3 parser.
Validates that ``NemotronV3Parser`` correctly handles:
- ``<think>``/``</think>`` reasoning with ``<tool_call>`` XML tool calls
(same format as Qwen3)
- Nemotron-specific reasoning/content swap when ``enable_thinking=False``
or ``force_nonempty_content=True``
"""
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_function_name,
collect_tool_arguments,
simulate_tool_streaming,
)
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
)
from vllm.parser.nemotron_v3 import NemotronV3Parser
_THINK_START_ID = 50
_THINK_END_ID = 51
_TOOL_CALL_ID = 60
_TOOL_CALL_END_ID = 61
_TEXT_ID = 100
_VOCAB = {
"<think>": _THINK_START_ID,
"</think>": _THINK_END_ID,
"<tool_call>": _TOOL_CALL_ID,
"</tool_call>": _TOOL_CALL_END_ID,
}
def _make_request(**chat_template_kwargs):
request = MagicMock(spec=ChatCompletionRequest)
request.tools = []
request.tool_choice = "auto"
request.include_reasoning = True
request.chat_template_kwargs = chat_template_kwargs or None
return request
@pytest.fixture
def parser():
return NemotronV3Parser(make_mock_tokenizer(_VOCAB))
class TestNemotronSwap:
def test_enable_thinking_false_swaps(self, parser):
"""When enable_thinking=False, model output without think tags
should have reasoning swapped to content."""
text = "The answer is 42."
request = _make_request(enable_thinking=False)
reasoning, content = parser.extract_reasoning(text, request)
assert content == "The answer is 42."
assert reasoning is None
def test_force_nonempty_content_swaps(self, parser):
"""force_nonempty_content=True triggers swap when content empty."""
text = "The answer is 42."
request = _make_request(force_nonempty_content=True)
reasoning, content = parser.extract_reasoning(text, request)
assert content == "The answer is 42."
assert reasoning is None
def test_no_swap_when_content_exists(self, parser):
"""With enable_thinking=False but real </think> giving content,
no swap occurs."""
text = "Some reasoning.</think>Actual content here."
request = _make_request(enable_thinking=False)
reasoning, content = parser.extract_reasoning(text, request)
assert reasoning == "Some reasoning."
assert content == "Actual content here."
def test_no_swap_when_enable_thinking_true(self, parser):
"""Normal thinking mode: no swap, even when content is empty."""
text = "Still thinking..."
request = _make_request(enable_thinking=True)
reasoning, content = parser.extract_reasoning(text, request)
assert reasoning == "Still thinking..."
assert content is None
def test_no_swap_with_none_request(self, parser):
"""Graceful handling when request is None."""
text = "Some text."
reasoning, content = parser.extract_reasoning(text, None)
assert reasoning == "Some text."
assert content is None
def test_no_swap_with_no_kwargs(self, parser):
"""No swap when chat_template_kwargs is absent."""
text = "Some text."
request = _make_request()
reasoning, content = parser.extract_reasoning(text, request)
assert reasoning == "Some text."
assert content is None
def test_swap_with_whitespace_only_content(self, parser):
"""Swap occurs when content is whitespace-only."""
text = "The answer.</think> "
request = _make_request(enable_thinking=False)
reasoning, content = parser.extract_reasoning(text, request)
assert content == "The answer."
assert reasoning == " "
class TestNonStreamingToolCalls:
def test_single_tool_call(self, parser):
text = (
"<tool_call>\n"
"<function=get_weather>\n"
"<parameter=city>Tokyo</parameter>\n"
"</function>\n"
"</tool_call>"
)
request = _make_request()
result = parser.extract_tool_calls(text, 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):
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>"
)
request = _make_request()
result = parser.extract_tool_calls(text, 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_no_tool_calls(self, parser):
request = _make_request()
result = parser.extract_tool_calls("Hello, how can I help?", request)
assert result.tools_called is False
# Parser starts in REASONING state, so plain text is classified
# as reasoning (not content) when there are no tool calls.
assert result.content is None
class TestStreaming:
def test_streaming_tool_calls(self, parser):
request = _make_request()
chunks = [
"<tool_call>\n",
"<function=get_weather>\n",
"<parameter=city>Tokyo",
"</parameter>\n",
"</function>\n",
"</tool_call>",
]
results = simulate_tool_streaming(parser, 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"}
class TestParseDeltaTokenIdFiltering:
"""parse_delta must not trigger tool call parsing when <tool_call>
appears as regular text rather than as a special token ID."""
def test_tool_call_text_in_reasoning_is_not_parsed(self, parser):
"""Literal <tool_call> in model reasoning should be content,
not a tool call."""
request = _make_request()
text = (
"The test uses <tool_call> syntax:\n"
"<tool_call>\n"
"<function=Bash>\n"
"<parameter=command>ls</parameter>\n"
"</function>\n"
"</tool_call>"
)
result = parser.parse_delta(
delta_text=text,
delta_token_ids=[_TEXT_ID] * 6,
request=request,
prompt_token_ids=[],
finished=True,
)
assert result is not None
assert result.reasoning is not None
assert "<tool_call>" in result.reasoning
assert not result.tool_calls
def test_special_token_id_still_triggers_tool_call(self, parser):
"""When the scanner matches a special token ID, the tool call
must still be parsed correctly."""
request = _make_request()
parser.parse_delta(
delta_text="Let me check.",
delta_token_ids=[_TEXT_ID, _TEXT_ID, _TEXT_ID],
request=request,
prompt_token_ids=[],
finished=False,
)
parser.parse_delta(
delta_text="<tool_call>",
delta_token_ids=[_TOOL_CALL_ID],
request=request,
finished=False,
)
parser.parse_delta(
delta_text=(
"\n<function=get_weather>\n"
"<parameter=city>Tokyo</parameter>\n"
"</function>\n"
),
delta_token_ids=[_TEXT_ID] * 5,
request=request,
finished=False,
)
parser.parse_delta(
delta_text="</tool_call>",
delta_token_ids=[_TOOL_CALL_END_ID],
request=request,
finished=True,
)
assert any(s.name == "get_weather" for s in parser._tool_slots)
def test_text_discussion_then_real_tool_call(self, parser):
"""Model discusses tool syntax in reasoning, then makes a real
tool call via special tokens."""
request = _make_request()
r1 = parser.parse_delta(
delta_text="Use <tool_call> to invoke tools.",
delta_token_ids=[_TEXT_ID] * 6,
request=request,
prompt_token_ids=[],
finished=False,
)
r2 = parser.parse_delta(
delta_text="</think>",
delta_token_ids=[_THINK_END_ID],
request=request,
finished=False,
)
r3 = parser.parse_delta(
delta_text="<tool_call>",
delta_token_ids=[_TOOL_CALL_ID],
request=request,
finished=False,
)
r4 = parser.parse_delta(
delta_text=("\n<function=test>\n<parameter=x>1</parameter>\n</function>\n"),
delta_token_ids=[_TEXT_ID] * 4,
request=request,
finished=False,
)
r5 = parser.parse_delta(
delta_text="</tool_call>",
delta_token_ids=[_TOOL_CALL_END_ID],
request=request,
finished=True,
)
results = [r1, r2, r3, r4, r5]
reasoning = "".join(r.reasoning for r in results if r and r.reasoning)
assert "<tool_call>" in reasoning
names = [
tc.function.name
for r in results
if r and r.tool_calls
for tc in r.tool_calls
if tc.function and tc.function.name
]
assert "test" in names