main
py 752 lines 26.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for the engine-based Qwen3 reasoning parser.
4
5 Validates that ``Qwen3Parser`` correctly handles
6 ``<think>``/``</think>`` reasoning with Qwen3-specific extensions:
7 - ``<tool_call>`` as implicit reasoning end (terminal + token ID)
8 - Stripping ``<think>`` from generated output (old template compat)
9 - No terminal text (``</think>``, ``<tool_call>``) leaks into output
10 """
11
12 import dataclasses
13
14 import pytest
15
16 from tests.parser.engine.conftest import make_mock_tokenizer
17 from tests.parser.engine.streaming_helpers import simulate_reasoning_streaming
18 from vllm.parser.abstract_parser import DelegatingParser
19 from vllm.parser.engine.parser_engine_config import ParserState
20 from vllm.parser.engine.registered_adapters import (
21 Qwen3ParserReasoningAdapter,
22 Qwen3ParserToolAdapter,
23 )
24 from vllm.parser.qwen3 import Qwen3Parser, qwen3_config
25
26 _THINK_START_ID = 50
27 _THINK_END_ID = 51
28 _TOOL_CALL_ID = 60
29 _TOOL_CALL_END_ID = 61
30 _IM_START_ID = 70
31 _IM_END_ID = 71
32 _TEXT_ID = 100
33
34 _QWEN3_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 "<|im_start|>": _IM_START_ID,
40 "<|im_end|>": _IM_END_ID,
41 }
42
43
44 class _Qwen3DelegatingParser(DelegatingParser):
45 reasoning_parser_cls = Qwen3ParserReasoningAdapter
46 tool_parser_cls = Qwen3ParserToolAdapter
47
48
49 @pytest.fixture
50 def mock_tokenizer():
51 return make_mock_tokenizer(_QWEN3_VOCAB)
52
53
54 @pytest.fixture
55 def parser(mock_tokenizer):
56 return Qwen3Parser(mock_tokenizer)
57
58
59 class TestNonStreaming:
60 def test_reasoning_then_content(self, parser):
61 text = "<think>Let me analyze.</think>The answer is 42."
62 reasoning, content = parser.extract_reasoning(text, None)
63 assert reasoning == "Let me analyze."
64 assert content == "The answer is 42."
65
66 def test_no_start_token_in_output(self, parser):
67 """Qwen3.5+ style: <think> in prompt, only </think> in output."""
68 text = "Let me think about this.</think>The answer is 42."
69 reasoning, content = parser.extract_reasoning(text, None)
70 assert reasoning == "Let me think about this."
71 assert content == "The answer is 42."
72
73 def test_reasoning_only(self, parser):
74 text = "<think>Still thinking...</think>"
75 reasoning, content = parser.extract_reasoning(text, None)
76 assert reasoning == "Still thinking..."
77 assert content is None
78
79 def test_no_end_tag_all_reasoning(self, parser):
80 """No </think> means truncated output — everything is reasoning."""
81 text = "Hello, no reasoning here."
82 reasoning, content = parser.extract_reasoning(text, None)
83 assert reasoning == "Hello, no reasoning here."
84 assert content is None
85
86 def test_multiline_reasoning(self, parser):
87 text = (
88 "<think>Step 1: parse.\nStep 2: compute.\nStep 3: output.</think>Result: 7."
89 )
90 reasoning, content = parser.extract_reasoning(text, None)
91 assert "Step 1" in reasoning
92 assert "Step 3" in reasoning
93 assert content == "Result: 7."
94
95 def test_tool_call_implicit_end(self, parser):
96 """<tool_call> without </think> acts as implicit reasoning end."""
97 text = (
98 "<think>I need to read the file.\n\n"
99 "<tool_call>\n<function=bash>\n"
100 "<parameter=cmd>ls</parameter>\n"
101 "</function>\n</tool_call>"
102 )
103 reasoning, content = parser.extract_reasoning(text, None)
104 assert reasoning == "I need to read the file.\n\n"
105 assert "</think>" not in reasoning
106 assert "<tool_call>" not in reasoning
107
108 def test_tool_call_implicit_end_no_think(self, parser):
109 """<tool_call> as implicit end, no <think> in output."""
110 text = (
111 "I need to read the file.\n\n"
112 "<tool_call>\n<function=bash>\n"
113 "<parameter=cmd>ls</parameter>\n"
114 "</function>\n</tool_call>"
115 )
116 reasoning, content = parser.extract_reasoning(text, None)
117 assert reasoning == "I need to read the file.\n\n"
118 assert "<tool_call>" not in reasoning
119
120 def test_live_scenario_think_end_before_tool_call(self, parser):
121 """Real model output: </think> immediately before <tool_call>.
122
123 Regression test for the bug where </think> and <parameter=...>
124 leaked into reasoning content.
125 """
126 text = (
127 "The user wants to see what files are in the current directory"
128 " and their contents. Let me start by listing the directory."
129 "</think><tool_call><function=read>"
130 "<parameter=filePath>/Users/test/demo</parameter>"
131 "</function></tool_call>"
132 )
133 reasoning, content = parser.extract_reasoning(text, None)
134 expected_reasoning = (
135 "The user wants to see what files are in the current directory"
136 " and their contents. Let me start by listing the directory."
137 )
138 assert reasoning == expected_reasoning
139 assert "</think>" not in reasoning
140 assert "<tool_call>" not in reasoning
141 assert "<parameter=" not in reasoning
142
143 def test_no_terminal_text_in_reasoning(self, parser):
144 """Terminal text must never appear in reasoning output."""
145 text = "Reasoning here.</think>Content here."
146 reasoning, content = parser.extract_reasoning(text, None)
147 assert "</think>" not in (reasoning or "")
148 assert "<think>" not in (reasoning or "")
149
150 def test_no_terminal_text_in_content(self, parser):
151 """Terminal text must never appear in content output."""
152 text = "Reasoning here.</think>Content here."
153 reasoning, content = parser.extract_reasoning(text, None)
154 assert "</think>" not in (content or "")
155 assert "<think>" not in (content or "")
156
157 def test_duplicate_think_end_absorbed(self, parser):
158 """Duplicate </think> in CONTENT state must not leak."""
159 text = "Reasoning here.</think>Content here.</think>More content."
160 reasoning, content = parser.extract_reasoning(text, None)
161 assert reasoning == "Reasoning here."
162 assert content == "Content here.More content."
163
164
165 class TestIsReasoningEnd:
166 def test_think_end_token(self, parser):
167 assert parser.is_reasoning_end([_THINK_START_ID, 1, _THINK_END_ID])
168
169 def test_no_end_token(self, parser):
170 assert not parser.is_reasoning_end([_THINK_START_ID, 1, 2])
171
172 def test_start_after_end_means_not_ended(self, parser):
173 assert not parser.is_reasoning_end([_THINK_END_ID, _THINK_START_ID, 1])
174
175 def test_tool_call_as_implicit_end(self, parser):
176 """Unpaired <tool_call> is implicit reasoning end."""
177 assert parser.is_reasoning_end([_THINK_START_ID, 1, _TOOL_CALL_ID])
178
179 def test_prompt_tool_example_before_generation_think_not_end(self, parser):
180 """Tool examples before the generation <think> must not end reasoning."""
181 assert not parser.is_reasoning_end([_TOOL_CALL_ID, _TEXT_ID, _THINK_START_ID])
182
183 def test_completed_tool_call_after_open_think_is_end(self, parser):
184 assert parser.is_reasoning_end(
185 [_THINK_START_ID, 1, _TOOL_CALL_ID, 2, _TOOL_CALL_END_ID]
186 )
187
188 def test_tool_call_after_think_end(self, parser):
189 """<tool_call> after </think> — already ended."""
190 assert parser.is_reasoning_end(
191 [_THINK_START_ID, 1, _THINK_END_ID, _TOOL_CALL_ID]
192 )
193
194 def test_empty_ids(self, parser):
195 assert not parser.is_reasoning_end([])
196
197
198 class TestIsReasoningEndDerivedFromGrammar:
199 @pytest.mark.parametrize(
200 ("ids", "thinking", "ended"),
201 [
202 # Explicit close, and the implicit close on <tool_call>.
203 ([_THINK_START_ID, 1, _THINK_END_ID], True, True),
204 ([_THINK_START_ID, 1, _TOOL_CALL_ID], True, True),
205 # Still inside the block.
206 ([_THINK_START_ID, 1], True, False),
207 ([_THINK_END_ID, _THINK_START_ID, 1], True, False),
208 # Uncommitted: a bare turn, an empty sequence. Thinking on starts
209 # in REASONING, so reasoning is expected.
210 ([_IM_END_ID, _IM_START_ID, 1], True, False),
211 ([], True, False),
212 # Thinking off starts in CONTENT with no way into REASONING, so
213 # a <think> the template left behind is inert, and a silent tail
214 # is ended because no block is expected.
215 ([_THINK_START_ID, 1], False, True),
216 ([_THINK_END_ID, _THINK_START_ID, 1], False, True),
217 ([_IM_END_ID, _IM_START_ID, 1], False, True),
218 ([], False, True),
219 ],
220 )
221 def test_is_reasoning_end(self, mock_tokenizer, ids, thinking, ended):
222 parser = Qwen3Parser(
223 mock_tokenizer, chat_template_kwargs={"enable_thinking": thinking}
224 )
225 assert parser.is_reasoning_end(ids) is ended
226
227 @pytest.mark.parametrize("thinking", [True, False])
228 def test_wait_for_reasoning_follows_thinking(self, mock_tokenizer, thinking):
229 parser = Qwen3Parser(
230 mock_tokenizer, chat_template_kwargs={"enable_thinking": thinking}
231 )
232 assert parser.parser_engine_config.wait_for_reasoning is thinking
233
234
235 class TestIsReasoningEndTurnBoundaries:
236 """Reasoning markers from earlier turns must not mark the new turn's
237 reasoning as finished (think-marker injection / interleaved-thinking
238 replay).
239 """
240
241 def test_injected_think_end_in_history_not_end(self, parser):
242 assert not parser.is_reasoning_end(
243 [_IM_START_ID, _TEXT_ID, _THINK_END_ID, _IM_END_ID, _IM_START_ID]
244 )
245
246 def test_replayed_reasoning_block_in_history_not_end(self, parser):
247 assert not parser.is_reasoning_end(
248 [
249 _IM_START_ID,
250 _THINK_START_ID,
251 _TEXT_ID,
252 _THINK_END_ID,
253 _TEXT_ID,
254 _IM_END_ID,
255 _IM_START_ID,
256 ]
257 )
258
259 def test_think_end_in_current_turn_is_end(self, parser):
260 assert parser.is_reasoning_end(
261 [_IM_START_ID, _TEXT_ID, _IM_END_ID, _IM_START_ID, _THINK_END_ID]
262 )
263
264 def test_unpaired_tool_call_in_history_not_end(self, parser):
265 assert not parser.is_reasoning_end(
266 [_TOOL_CALL_ID, _TEXT_ID, _IM_END_ID, _IM_START_ID]
267 )
268
269 def test_continued_turn_with_closed_thinking_is_end(self, parser):
270 assert parser.is_reasoning_end(
271 [_IM_START_ID, _THINK_START_ID, _TEXT_ID, _THINK_END_ID, _TEXT_ID]
272 )
273
274 def test_continued_turn_with_open_thinking_not_end(self, parser):
275 assert not parser.is_reasoning_end([_IM_START_ID, _THINK_START_ID, _TEXT_ID])
276
277 def test_empty_boundary_config_keeps_global_walk(self):
278 """Configs without turn_boundary_tokens keep the pre-existing
279 behavior: any </think> in the sequence ends reasoning."""
280 cfg = qwen3_config(turn_boundary_tokens=frozenset())
281 parser = Qwen3Parser(
282 make_mock_tokenizer(_QWEN3_VOCAB), parser_engine_config=cfg
283 )
284 assert parser.is_reasoning_end(
285 [_IM_START_ID, _TEXT_ID, _THINK_END_ID, _IM_END_ID, _IM_START_ID]
286 )
287
288 def test_boundary_tokens_missing_from_vocab_keep_global_walk(self):
289 """Boundary tokens absent from the vocabulary resolve to nothing,
290 so subclasses reusing the default on a non-ChatML vocab keep the
291 pre-existing behavior."""
292 vocab = {
293 token: token_id
294 for token, token_id in _QWEN3_VOCAB.items()
295 if not token.startswith("<|im_")
296 }
297 parser = Qwen3Parser(make_mock_tokenizer(vocab))
298 assert parser.is_reasoning_end([_TEXT_ID, _THINK_END_ID, _TEXT_ID])
299
300 def test_boundary_with_thinking_disabled_is_end(self, mock_tokenizer):
301 """With thinking disabled (initial state CONTENT) a walk that stops at a turn
302 boundary must report reasoning as ended: the model never emits a marker."""
303 parser = Qwen3Parser(
304 mock_tokenizer, chat_template_kwargs={"enable_thinking": False}
305 )
306 assert parser.is_reasoning_end([_IM_START_ID, _TEXT_ID])
307
308 def test_boundary_with_thinking_enabled_not_end(self, parser):
309 """With thinking disabled (initial state REASONING) a walk that stops at a turn
310 boundary must keep reasoning open"""
311 assert not parser.is_reasoning_end([_IM_START_ID, _TEXT_ID])
312
313
314 class TestDelegatingPromptDetection:
315 def test_prompt_tool_example_does_not_skip_streaming_reasoning(
316 self, mock_tokenizer, mock_request
317 ):
318 parser = _Qwen3DelegatingParser(mock_tokenizer)
319 prompt_ids = [_TOOL_CALL_ID, _TEXT_ID, _THINK_START_ID]
320
321 delta = parser.parse_delta(
322 "thinking",
323 [_TEXT_ID],
324 mock_request,
325 prompt_token_ids=prompt_ids,
326 finished=False,
327 )
328
329 assert delta is not None
330 assert delta.reasoning == "thinking"
331 assert delta.content is None
332
333 def test_injected_think_end_in_history_keeps_streaming_reasoning(
334 self, mock_tokenizer, mock_request
335 ):
336 parser = _Qwen3DelegatingParser(mock_tokenizer)
337 prompt_ids = [
338 _IM_START_ID,
339 _TEXT_ID,
340 _THINK_END_ID,
341 _TEXT_ID,
342 _IM_END_ID,
343 _IM_START_ID,
344 ]
345
346 delta = parser.parse_delta(
347 "thinking",
348 [_TEXT_ID],
349 mock_request,
350 prompt_token_ids=prompt_ids,
351 finished=False,
352 )
353
354 assert delta is not None
355 assert delta.reasoning == "thinking"
356 assert delta.content is None
357
358 def test_think_end_in_generation_prompt_still_seeds_reasoning_ended(
359 self, mock_tokenizer, mock_request
360 ):
361 parser = _Qwen3DelegatingParser(mock_tokenizer)
362 prompt_ids = [
363 _IM_START_ID,
364 _TEXT_ID,
365 _IM_END_ID,
366 _IM_START_ID,
367 _THINK_START_ID,
368 _THINK_END_ID,
369 ]
370
371 delta = parser.parse_delta(
372 "answer",
373 [_TEXT_ID],
374 mock_request,
375 prompt_token_ids=prompt_ids,
376 finished=False,
377 )
378
379 assert delta is not None
380 assert delta.reasoning is None
381 assert delta.content == "answer"
382
383
384 class TestStreaming:
385 def test_basic_streaming(self, parser):
386 reasoning, content = simulate_reasoning_streaming(
387 parser,
388 ["<think>", "thinking", " hard", "</think>", "done"],
389 [
390 (_THINK_START_ID,),
391 (1,),
392 (2,),
393 (_THINK_END_ID,),
394 (3,),
395 ],
396 )
397 assert reasoning == "thinking hard"
398 assert content == "done"
399
400 def test_streaming_no_start_token(self, parser):
401 """Qwen3.5 style: no <think> in output, just reasoning then </think>."""
402 reasoning, content = simulate_reasoning_streaming(
403 parser,
404 ["reasoning ", "text", "</think>", "content"],
405 [
406 (1,),
407 (2,),
408 (_THINK_END_ID,),
409 (3,),
410 ],
411 )
412 assert reasoning == "reasoning text"
413 assert content == "content"
414
415 def test_streaming_start_token_stripped(self, parser):
416 """<think> in output (old template) should be stripped."""
417 reasoning, content = simulate_reasoning_streaming(
418 parser,
419 ["<think>reasoning", "</think>", "content"],
420 [
421 (_THINK_START_ID, 1),
422 (_THINK_END_ID,),
423 (2,),
424 ],
425 )
426 assert reasoning == "reasoning"
427 assert content == "content"
428
429 def test_streaming_tool_call_implicit_end(self, parser):
430 """<tool_call> ends reasoning implicitly during streaming."""
431 reasoning, content = simulate_reasoning_streaming(
432 parser,
433 ["I need to check.", "<tool_call>", "\n<function=test>"],
434 [
435 (1,),
436 (_TOOL_CALL_ID,),
437 (2,),
438 ],
439 )
440 assert reasoning == "I need to check."
441 assert "<tool_call>" not in reasoning
442 assert "</think>" not in reasoning
443 assert content is not None
444
445 def test_streaming_content_after_think_end(self, parser):
446 """Content deltas after </think> are routed as content."""
447 reasoning, content = simulate_reasoning_streaming(
448 parser,
449 ["reasoning", "</think>", "content1", " content2"],
450 [
451 (1,),
452 (_THINK_END_ID,),
453 (2,),
454 (3,),
455 ],
456 )
457 assert reasoning == "reasoning"
458 assert content == "content1 content2"
459
460 def test_streaming_content_after_tool_call(self, parser):
461 """Content deltas after <tool_call> are routed as content."""
462 reasoning, content = simulate_reasoning_streaming(
463 parser,
464 ["thinking", "<tool_call>", "<function=f>"],
465 [
466 (1,),
467 (_TOOL_CALL_ID,),
468 (2,),
469 ],
470 )
471 assert reasoning == "thinking"
472 assert "<tool_call>" not in reasoning
473 assert content is not None
474
475 def test_streaming_end_grouped_with_content(self, parser):
476 """</think> grouped with following content in one delta."""
477 reasoning, content = simulate_reasoning_streaming(
478 parser,
479 ["reasoning", "</think>the answer"],
480 [
481 (1,),
482 (_THINK_END_ID, 2),
483 ],
484 )
485 assert reasoning == "reasoning"
486 assert content == "the answer"
487
488 def test_streaming_think_and_end_in_one_delta(self, parser):
489 """<think> and </think> in the same delta."""
490 reasoning, content = simulate_reasoning_streaming(
491 parser,
492 ["<think>reasoning</think>"],
493 [
494 (_THINK_START_ID, 1, _THINK_END_ID),
495 ],
496 )
497 assert reasoning == "reasoning"
498 assert content == ""
499
500 def test_streaming_pure_content_no_think(self, parser):
501 """No think tokens at all — everything is reasoning (truncated)."""
502 reasoning, content = simulate_reasoning_streaming(
503 parser,
504 ["hello ", "world"],
505 [
506 (1,),
507 (2,),
508 ],
509 )
510 assert reasoning == "hello world"
511 assert content == ""
512
513 def test_streaming_think_end_and_tool_call_same_delta(self, parser):
514 """</think> and <tool_call> in the same delta — no leakage.
515
516 Regression test: the old override split at <tool_call> without
517 stripping </think>, causing </think> to leak into reasoning.
518 """
519 reasoning, content = simulate_reasoning_streaming(
520 parser,
521 [
522 "Let me list the directory.",
523 "</think><tool_call>",
524 "<function=read>",
525 "<parameter=filePath>/tmp</parameter>",
526 ],
527 [
528 (1,),
529 (_THINK_END_ID, _TOOL_CALL_ID),
530 (2,),
531 (3,),
532 ],
533 )
534 assert reasoning == "Let me list the directory."
535 assert "</think>" not in reasoning
536 assert "<tool_call>" not in reasoning
537 assert "<parameter=" not in reasoning
538 assert content is not None
539
540 def test_streaming_no_terminal_text_leaks(self, parser):
541 """Terminal text must never appear in reasoning or content."""
542 reasoning, content = simulate_reasoning_streaming(
543 parser,
544 ["reasoning", "</think>", "content"],
545 [
546 (1,),
547 (_THINK_END_ID,),
548 (2,),
549 ],
550 )
551 assert "</think>" not in reasoning
552 assert "</think>" not in content
553 assert "<think>" not in reasoning
554
555 def test_streaming_duplicate_think_end_absorbed(self, parser):
556 """Duplicate </think> token in CONTENT state must not leak."""
557 reasoning, content = simulate_reasoning_streaming(
558 parser,
559 ["reasoning", "</think>", "content", "</think>", "more"],
560 [
561 (1,),
562 (_THINK_END_ID,),
563 (2,),
564 (_THINK_END_ID,),
565 (3,),
566 ],
567 )
568 assert reasoning == "reasoning"
569 assert content == "contentmore"
570
571
572 class TestTrailingWhitespaceStripping:
573 """When strip_trailing_reasoning_whitespace is True,
574 trailing whitespace before </think> must be stripped.
575
576 Models often generate trailing newlines before </think>, and these
577 accumulate across multi-turn conversations via a feedback loop.
578 """
579
580 @pytest.fixture
581 def parser_with_strip(self):
582 cfg = dataclasses.replace(
583 qwen3_config(),
584 strip_trailing_reasoning_whitespace=True,
585 )
586 return Qwen3Parser(make_mock_tokenizer(_QWEN3_VOCAB), parser_engine_config=cfg)
587
588 def test_non_streaming_trailing_newline(self, parser_with_strip):
589 text = "Reasoning here.\n</think>Content."
590 reasoning, content = parser_with_strip.extract_reasoning(text, None)
591 assert reasoning == "Reasoning here."
592 assert content == "Content."
593
594 def test_non_streaming_multiple_trailing_newlines(self, parser_with_strip):
595 text = "Reasoning here.\n\n\n</think>Content."
596 reasoning, content = parser_with_strip.extract_reasoning(text, None)
597 assert reasoning == "Reasoning here."
598 assert content == "Content."
599
600 def test_non_streaming_internal_newlines_preserved(self, parser_with_strip):
601 text = "Step 1.\n\nStep 2.\n\nStep 3.</think>Answer."
602 reasoning, content = parser_with_strip.extract_reasoning(text, None)
603 assert reasoning == "Step 1.\n\nStep 2.\n\nStep 3."
604 assert content == "Answer."
605
606 def test_non_streaming_only_newlines_becomes_none(self, parser_with_strip):
607 text = "\n\n\n</think>Content."
608 reasoning, content = parser_with_strip.extract_reasoning(text, None)
609 assert reasoning is None
610 assert content == "Content."
611
612 def test_streaming_trailing_newline_stripped(self, parser_with_strip):
613 reasoning, content = simulate_reasoning_streaming(
614 parser_with_strip,
615 ["thinking.\n", "</think>", "done"],
616 [
617 (1,),
618 (_THINK_END_ID,),
619 (2,),
620 ],
621 )
622 assert reasoning == "thinking."
623 assert content == "done"
624
625 def test_streaming_multiple_trailing_newlines_stripped(self, parser_with_strip):
626 reasoning, content = simulate_reasoning_streaming(
627 parser_with_strip,
628 ["thinking.\n", "\n", "\n", "</think>", "done"],
629 [
630 (1,),
631 (2,),
632 (3,),
633 (_THINK_END_ID,),
634 (4,),
635 ],
636 )
637 assert reasoning == "thinking."
638 assert content == "done"
639
640 def test_streaming_internal_newlines_preserved(self, parser_with_strip):
641 reasoning, content = simulate_reasoning_streaming(
642 parser_with_strip,
643 ["Step 1.\n", "\nStep 2.\n", "</think>", "Answer"],
644 [
645 (1,),
646 (2,),
647 (_THINK_END_ID,),
648 (3,),
649 ],
650 )
651 assert reasoning == "Step 1.\n\nStep 2."
652 assert content == "Answer"
653
654 def test_streaming_trailing_newlines_before_tool_call(self, parser_with_strip):
655 """Trailing newlines before implicit <tool_call> end are stripped."""
656 reasoning, content = simulate_reasoning_streaming(
657 parser_with_strip,
658 ["I'll check.\n\n", "<tool_call>", "<function=test>"],
659 [
660 (1,),
661 (_TOOL_CALL_ID,),
662 (2,),
663 ],
664 )
665 assert reasoning == "I'll check."
666 assert "<tool_call>" not in reasoning
667
668
669 class TestWhitespaceStrippingDisabled:
670 """When strip_trailing_reasoning_whitespace is False,
671 trailing whitespace in reasoning must be preserved."""
672
673 @pytest.fixture
674 def parser_no_strip(self):
675 cfg = dataclasses.replace(
676 qwen3_config(),
677 strip_trailing_reasoning_whitespace=False,
678 )
679 return Qwen3Parser(make_mock_tokenizer(_QWEN3_VOCAB), parser_engine_config=cfg)
680
681 def test_non_streaming_preserves_trailing_newline(self, parser_no_strip):
682 text = "Reasoning here.\n</think>Content."
683 reasoning, content = parser_no_strip.extract_reasoning(text, None)
684 assert reasoning == "Reasoning here.\n"
685 assert content == "Content."
686
687 def test_streaming_preserves_trailing_newlines(self, parser_no_strip):
688 reasoning, content = simulate_reasoning_streaming(
689 parser_no_strip,
690 ["thinking.\n", "\n", "</think>", "done"],
691 [
692 (1,),
693 (2,),
694 (_THINK_END_ID,),
695 (3,),
696 ],
697 )
698 assert reasoning == "thinking.\n\n"
699 assert content == "done"
700
701
702 class TestThinkingDisabled:
703 """When ``enable_thinking=False``, the chat template pre-fills a closed
704 ``<think>\\n\\n</think>\\n\\n`` block. The model output starts in content
705 state, so the parser's initial state must be CONTENT — not REASONING.
706 """
707
708 def test_thinking_disabled_initial_state_is_content(self, mock_tokenizer):
709 p = Qwen3Parser(
710 mock_tokenizer,
711 chat_template_kwargs={"enable_thinking": False},
712 )
713 assert p.parser_engine_config.initial_state == ParserState.CONTENT
714
715 def test_thinking_enabled_initial_state_is_reasoning(self, mock_tokenizer):
716 p = Qwen3Parser(
717 mock_tokenizer,
718 chat_template_kwargs={"enable_thinking": True},
719 )
720 assert p.parser_engine_config.initial_state == ParserState.REASONING
721
722 def test_default_initial_state_is_reasoning(self, mock_tokenizer):
723 p = Qwen3Parser(mock_tokenizer)
724 assert p.parser_engine_config.initial_state == ParserState.REASONING
725
726 def test_thinking_disabled_streaming_content_only(self, mock_tokenizer):
727 """Plain text with thinking disabled must stream as content, not
728 reasoning. Before the fix, the REASONING initial state caused all
729 output to be emitted as reasoning chunks."""
730 p = Qwen3Parser(
731 mock_tokenizer,
732 chat_template_kwargs={"enable_thinking": False},
733 )
734 reasoning, content = simulate_reasoning_streaming(
735 p,
736 ["The answer", " is 42."],
737 [
738 (_TEXT_ID,),
739 (_TEXT_ID,),
740 ],
741 )
742 assert content == "The answer is 42."
743 assert reasoning == ""
744
745 def test_thinking_disabled_non_streaming(self, mock_tokenizer):
746 p = Qwen3Parser(
747 mock_tokenizer,
748 chat_template_kwargs={"enable_thinking": False},
749 )
750 reasoning, content = p.extract_reasoning("The answer is 42.", None)
751 assert reasoning is None
752 assert content == "The answer is 42."