main
py 303 lines 9.99 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for the engine-based Nemotron V3 parser.
4
5 Validates that ``NemotronV3Parser`` correctly handles:
6 - ``<think>``/``</think>`` reasoning with ``<tool_call>`` XML tool calls
7 (same format as Qwen3)
8 - Nemotron-specific reasoning/content swap when ``enable_thinking=False``
9 or ``force_nonempty_content=True``
10 """
11
12 import json
13 from unittest.mock import MagicMock
14
15 import pytest
16
17 from tests.parser.engine.conftest import make_mock_tokenizer
18 from tests.parser.engine.streaming_helpers import (
19 collect_function_name,
20 collect_tool_arguments,
21 simulate_tool_streaming,
22 )
23 from vllm.entrypoints.openai.chat_completion.protocol import (
24 ChatCompletionRequest,
25 )
26 from vllm.parser.nemotron_v3 import NemotronV3Parser
27
28 _THINK_START_ID = 50
29 _THINK_END_ID = 51
30 _TOOL_CALL_ID = 60
31 _TOOL_CALL_END_ID = 61
32 _TEXT_ID = 100
33
34 _VOCAB = {
35 "<think>": _THINK_START_ID,
36 "</think>": _THINK_END_ID,
37 "<tool_call>": _TOOL_CALL_ID,
38 "</tool_call>": _TOOL_CALL_END_ID,
39 }
40
41
42 def _make_request(**chat_template_kwargs):
43 request = MagicMock(spec=ChatCompletionRequest)
44 request.tools = []
45 request.tool_choice = "auto"
46 request.include_reasoning = True
47 request.chat_template_kwargs = chat_template_kwargs or None
48 return request
49
50
51 @pytest.fixture
52 def parser():
53 return NemotronV3Parser(make_mock_tokenizer(_VOCAB))
54
55
56 class TestNemotronSwap:
57 def test_enable_thinking_false_swaps(self, parser):
58 """When enable_thinking=False, model output without think tags
59 should have reasoning swapped to content."""
60 text = "The answer is 42."
61 request = _make_request(enable_thinking=False)
62 reasoning, content = parser.extract_reasoning(text, request)
63 assert content == "The answer is 42."
64 assert reasoning is None
65
66 def test_force_nonempty_content_swaps(self, parser):
67 """force_nonempty_content=True triggers swap when content empty."""
68 text = "The answer is 42."
69 request = _make_request(force_nonempty_content=True)
70 reasoning, content = parser.extract_reasoning(text, request)
71 assert content == "The answer is 42."
72 assert reasoning is None
73
74 def test_no_swap_when_content_exists(self, parser):
75 """With enable_thinking=False but real </think> giving content,
76 no swap occurs."""
77 text = "Some reasoning.</think>Actual content here."
78 request = _make_request(enable_thinking=False)
79 reasoning, content = parser.extract_reasoning(text, request)
80 assert reasoning == "Some reasoning."
81 assert content == "Actual content here."
82
83 def test_no_swap_when_enable_thinking_true(self, parser):
84 """Normal thinking mode: no swap, even when content is empty."""
85 text = "Still thinking..."
86 request = _make_request(enable_thinking=True)
87 reasoning, content = parser.extract_reasoning(text, request)
88 assert reasoning == "Still thinking..."
89 assert content is None
90
91 def test_no_swap_with_none_request(self, parser):
92 """Graceful handling when request is None."""
93 text = "Some text."
94 reasoning, content = parser.extract_reasoning(text, None)
95 assert reasoning == "Some text."
96 assert content is None
97
98 def test_no_swap_with_no_kwargs(self, parser):
99 """No swap when chat_template_kwargs is absent."""
100 text = "Some text."
101 request = _make_request()
102 reasoning, content = parser.extract_reasoning(text, request)
103 assert reasoning == "Some text."
104 assert content is None
105
106 def test_swap_with_whitespace_only_content(self, parser):
107 """Swap occurs when content is whitespace-only."""
108 text = "The answer.</think> "
109 request = _make_request(enable_thinking=False)
110 reasoning, content = parser.extract_reasoning(text, request)
111 assert content == "The answer."
112 assert reasoning == " "
113
114
115 class TestNonStreamingToolCalls:
116 def test_single_tool_call(self, parser):
117 text = (
118 "<tool_call>\n"
119 "<function=get_weather>\n"
120 "<parameter=city>Tokyo</parameter>\n"
121 "</function>\n"
122 "</tool_call>"
123 )
124 request = _make_request()
125 result = parser.extract_tool_calls(text, request)
126 assert result.tools_called is True
127 assert len(result.tool_calls) == 1
128 assert result.tool_calls[0].function.name == "get_weather"
129 args = json.loads(result.tool_calls[0].function.arguments)
130 assert args == {"city": "Tokyo"}
131
132 def test_parallel_tool_calls(self, parser):
133 text = (
134 "<tool_call>\n"
135 "<function=get_weather>\n"
136 "<parameter=city>Tokyo</parameter>\n"
137 "</function>\n"
138 "</tool_call>"
139 "<tool_call>\n"
140 "<function=get_time>\n"
141 "<parameter=timezone>Asia/Tokyo</parameter>\n"
142 "</function>\n"
143 "</tool_call>"
144 )
145 request = _make_request()
146 result = parser.extract_tool_calls(text, request)
147 assert result.tools_called is True
148 assert len(result.tool_calls) == 2
149 assert result.tool_calls[0].function.name == "get_weather"
150 assert result.tool_calls[1].function.name == "get_time"
151
152 def test_no_tool_calls(self, parser):
153 request = _make_request()
154 result = parser.extract_tool_calls("Hello, how can I help?", request)
155 assert result.tools_called is False
156 # Parser starts in REASONING state, so plain text is classified
157 # as reasoning (not content) when there are no tool calls.
158 assert result.content is None
159
160
161 class TestStreaming:
162 def test_streaming_tool_calls(self, parser):
163 request = _make_request()
164 chunks = [
165 "<tool_call>\n",
166 "<function=get_weather>\n",
167 "<parameter=city>Tokyo",
168 "</parameter>\n",
169 "</function>\n",
170 "</tool_call>",
171 ]
172 results = simulate_tool_streaming(parser, request, chunks)
173 name = collect_function_name(results)
174 assert name == "get_weather"
175 args_text = collect_tool_arguments(results)
176 assert args_text
177 parsed = json.loads(args_text)
178 assert parsed == {"city": "Tokyo"}
179
180
181 class TestParseDeltaTokenIdFiltering:
182 """parse_delta must not trigger tool call parsing when <tool_call>
183 appears as regular text rather than as a special token ID."""
184
185 def test_tool_call_text_in_reasoning_is_not_parsed(self, parser):
186 """Literal <tool_call> in model reasoning should be content,
187 not a tool call."""
188 request = _make_request()
189
190 text = (
191 "The test uses <tool_call> syntax:\n"
192 "<tool_call>\n"
193 "<function=Bash>\n"
194 "<parameter=command>ls</parameter>\n"
195 "</function>\n"
196 "</tool_call>"
197 )
198 result = parser.parse_delta(
199 delta_text=text,
200 delta_token_ids=[_TEXT_ID] * 6,
201 request=request,
202 prompt_token_ids=[],
203 finished=True,
204 )
205
206 assert result is not None
207 assert result.reasoning is not None
208 assert "<tool_call>" in result.reasoning
209 assert not result.tool_calls
210
211 def test_special_token_id_still_triggers_tool_call(self, parser):
212 """When the scanner matches a special token ID, the tool call
213 must still be parsed correctly."""
214 request = _make_request()
215
216 parser.parse_delta(
217 delta_text="Let me check.",
218 delta_token_ids=[_TEXT_ID, _TEXT_ID, _TEXT_ID],
219 request=request,
220 prompt_token_ids=[],
221 finished=False,
222 )
223
224 parser.parse_delta(
225 delta_text="<tool_call>",
226 delta_token_ids=[_TOOL_CALL_ID],
227 request=request,
228 finished=False,
229 )
230
231 parser.parse_delta(
232 delta_text=(
233 "\n<function=get_weather>\n"
234 "<parameter=city>Tokyo</parameter>\n"
235 "</function>\n"
236 ),
237 delta_token_ids=[_TEXT_ID] * 5,
238 request=request,
239 finished=False,
240 )
241
242 parser.parse_delta(
243 delta_text="</tool_call>",
244 delta_token_ids=[_TOOL_CALL_END_ID],
245 request=request,
246 finished=True,
247 )
248
249 assert any(s.name == "get_weather" for s in parser._tool_slots)
250
251 def test_text_discussion_then_real_tool_call(self, parser):
252 """Model discusses tool syntax in reasoning, then makes a real
253 tool call via special tokens."""
254 request = _make_request()
255
256 r1 = parser.parse_delta(
257 delta_text="Use <tool_call> to invoke tools.",
258 delta_token_ids=[_TEXT_ID] * 6,
259 request=request,
260 prompt_token_ids=[],
261 finished=False,
262 )
263
264 r2 = parser.parse_delta(
265 delta_text="</think>",
266 delta_token_ids=[_THINK_END_ID],
267 request=request,
268 finished=False,
269 )
270
271 r3 = parser.parse_delta(
272 delta_text="<tool_call>",
273 delta_token_ids=[_TOOL_CALL_ID],
274 request=request,
275 finished=False,
276 )
277
278 r4 = parser.parse_delta(
279 delta_text=("\n<function=test>\n<parameter=x>1</parameter>\n</function>\n"),
280 delta_token_ids=[_TEXT_ID] * 4,
281 request=request,
282 finished=False,
283 )
284
285 r5 = parser.parse_delta(
286 delta_text="</tool_call>",
287 delta_token_ids=[_TOOL_CALL_END_ID],
288 request=request,
289 finished=True,
290 )
291
292 results = [r1, r2, r3, r4, r5]
293 reasoning = "".join(r.reasoning for r in results if r and r.reasoning)
294 assert "<tool_call>" in reasoning
295
296 names = [
297 tc.function.name
298 for r in results
299 if r and r.tool_calls
300 for tc in r.tool_calls
301 if tc.function and tc.function.name
302 ]
303 assert "test" in names