From 5ef58061609ab7a73cd75a7ac1fe63ea4ef63f43 Mon Sep 17 00:00:00 2001
From: roikoren755 <26850796+roikoren755@users.noreply.github.com>
Date: Mon, 16 Mar 2026 22:29:40 +0200
Subject: [PATCH] [Nemotron] Small reasoning parser fix (#20284)
---
python/sglang/srt/parser/reasoning_parser.py | 12 ++++
.../unit/parser/test_reasoning_parser.py | 69 +++++++++++++++++++
2 files changed, 81 insertions(+)
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."""