| 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 |