diff --git a/python/sglang/srt/parser/reasoning_parser.py b/python/sglang/srt/parser/reasoning_parser.py index 7eef5c0fb..d2d4d5380 100644 --- a/python/sglang/srt/parser/reasoning_parser.py +++ b/python/sglang/srt/parser/reasoning_parser.py @@ -431,6 +431,7 @@ class Nemotron3Detector(BaseReasoningFormatDetector): force_reasoning: bool = False, continue_final_message: bool = False, previous_content: str = "", + force_nonempty_content: bool = False, ): super().__init__( "", @@ -440,6 +441,13 @@ class Nemotron3Detector(BaseReasoningFormatDetector): continue_final_message=continue_final_message, previous_content=previous_content, ) + self._force_nonempty_content = force_nonempty_content + + def detect_and_parse(self, text: str) -> StreamingParseResult: + ret = super().detect_and_parse(text) + if self._force_nonempty_content and not ret.normal_text: + ret.normal_text, ret.reasoning_text = ret.reasoning_text, ret.normal_text + return ret class ReasoningParser: @@ -502,6 +510,10 @@ class ReasoningParser: kwargs["continue_final_message"] = True kwargs["previous_content"] = request.messages[-1].content + chat_template_kwargs = getattr(request, "chat_template_kwargs", None) or {} + if chat_template_kwargs.get("force_nonempty_content") is True: + kwargs["force_nonempty_content"] = True + self.detector = detector_class(**kwargs) def parse_non_stream(self, full_text: str) -> Tuple[Optional[str], Optional[str]]: diff --git a/test/registered/unit/parser/test_reasoning_parser.py b/test/registered/unit/parser/test_reasoning_parser.py index c6ae17aee..3e47281b4 100644 --- a/test/registered/unit/parser/test_reasoning_parser.py +++ b/test/registered/unit/parser/test_reasoning_parser.py @@ -6,6 +6,7 @@ from sglang.srt.parser.reasoning_parser import ( Glm45Detector, KimiDetector, KimiK2Detector, + Nemotron3Detector, Qwen3Detector, ReasoningParser, StreamingParseResult, @@ -512,6 +513,74 @@ class TestGlm45Detector(CustomTestCase): self.assertEqual(result.normal_text, "tool call") +class TestNemotron3Detector(CustomTestCase): + def setUp(self): + self.detector = Nemotron3Detector() + + def test_init(self): + """Test Nemotron3Detector initialization.""" + self.assertEqual(self.detector.think_start_token, "") + self.assertEqual(self.detector.think_end_token, "") + self.assertFalse(self.detector._in_reasoning) + self.assertTrue(self.detector.stream_reasoning) + self.assertFalse(self.detector._force_nonempty_content) + + def test_detect_and_parse_complete_reasoning(self): + """Test parsing complete reasoning block.""" + text = "Let me think about thisThe answer is 42." + result = self.detector.detect_and_parse(text) + self.assertEqual(result.reasoning_text, "Let me think about this") + self.assertEqual(result.normal_text, "The answer is 42.") + + def test_detect_and_parse_no_thinking(self): + """Test parsing without thinking tokens.""" + text = "Direct answer without thinking." + result = self.detector.detect_and_parse(text) + self.assertEqual(result.normal_text, text) + self.assertEqual(result.reasoning_text, "") + + def test_detect_and_parse_reasoning_only(self): + """Test parsing when output is all reasoning (no content after ).""" + text = "All reasoning, no answer" + result = self.detector.detect_and_parse(text) + self.assertEqual(result.reasoning_text, "All reasoning, no answer") + self.assertEqual(result.normal_text, "") + + def test_force_nonempty_content_swaps_when_no_normal_text(self): + """Test force_nonempty_content swaps reasoning to content when content is empty.""" + detector = Nemotron3Detector(force_nonempty_content=True) + text = "All reasoning, no answer" + result = detector.detect_and_parse(text) + self.assertEqual(result.normal_text, "All reasoning, no answer") + self.assertEqual(result.reasoning_text, "") + + def test_force_nonempty_content_no_swap_when_normal_text_exists(self): + """Test force_nonempty_content does not swap when content already exists.""" + detector = Nemotron3Detector(force_nonempty_content=True) + text = "Reasoning hereThe answer is 42." + result = detector.detect_and_parse(text) + self.assertEqual(result.reasoning_text, "Reasoning here") + self.assertEqual(result.normal_text, "The answer is 42.") + + def test_force_nonempty_content_truncated_reasoning(self): + """Test force_nonempty_content with truncated reasoning (no end token).""" + detector = Nemotron3Detector(force_nonempty_content=True) + text = "Truncated reasoning without end token" + result = detector.detect_and_parse(text) + # Truncated reasoning has no normal_text, so swap should occur + self.assertEqual(result.normal_text, "Truncated reasoning without end token") + self.assertEqual(result.reasoning_text, "") + + def test_force_nonempty_content_no_thinking_tokens(self): + """Test force_nonempty_content with plain text (no thinking tokens).""" + detector = Nemotron3Detector(force_nonempty_content=True) + text = "Plain text without any thinking." + result = detector.detect_and_parse(text) + # Normal text already exists, no swap needed + self.assertEqual(result.normal_text, text) + self.assertEqual(result.reasoning_text, "") + + class TestReasoningParser(CustomTestCase): def test_init_valid_model(self): """Test initialization with valid model types."""