diff --git a/python/sglang/srt/parser/reasoning_parser.py b/python/sglang/srt/parser/reasoning_parser.py
index f2c5541b4..1ed206fe2 100644
--- a/python/sglang/srt/parser/reasoning_parser.py
+++ b/python/sglang/srt/parser/reasoning_parser.py
@@ -1043,6 +1043,71 @@ class Nemotron3Detector(BaseReasoningFormatDetector):
)
+class GraniteThinkingDetector(BaseReasoningFormatDetector):
+ """Detector for Granite 4.2 thinking models (ibm-granite/granite-4.2-*).
+
+ Strips leading newlines from content after — the Granite chat
+ template writes ``\\n\\n``, so content starts with ``\\n``.
+ Matches the behavior of the HF plugin ``granite_thinking_parser.py``.
+ """
+
+ def __init__(
+ self,
+ stream_reasoning: bool = True,
+ force_reasoning: bool = False,
+ continue_final_message: bool = False,
+ previous_content: str = "",
+ force_nonempty_content: bool = False,
+ ):
+ super().__init__(
+ "",
+ "",
+ force_reasoning=force_reasoning,
+ stream_reasoning=stream_reasoning,
+ tool_start_token="",
+ continue_final_message=continue_final_message,
+ previous_content=previous_content,
+ reasoning_default="enable_thinking",
+ force_nonempty_content=force_nonempty_content,
+ )
+ self._content_started = False
+ self._reasoning_seen = False
+
+ def detect_and_parse(self, text: str) -> StreamingParseResult:
+ ret = self._detect_and_parse_impl(text)
+ think_end_present = self.think_end_token in text
+ # HF plugin swaps when parsed content is absent (text ends at );
+ # newline-only content the plugin strips itself, without swapping.
+ content_absent = think_end_present and not ret.normal_text
+ if think_end_present and ret.normal_text:
+ ret.normal_text = ret.normal_text.lstrip("\n")
+ if (
+ self._force_nonempty_content
+ and not ret.normal_text
+ and (content_absent or not think_end_present)
+ ):
+ ret.normal_text, ret.reasoning_text = ret.reasoning_text, ret.normal_text
+ return ret
+
+ def parse_streaming_increment(self, new_text: str) -> StreamingParseResult:
+ was_in_reasoning = self._in_reasoning
+ ret = super().parse_streaming_increment(new_text)
+ # Sampling only the pre-call state misses a whole think block arriving
+ # in one chunk; post-call evidence keeps stripping chunk-independent.
+ if (
+ was_in_reasoning
+ or self._in_reasoning
+ or self.stripped_think_start
+ or ret.reasoning_text
+ ):
+ self._reasoning_seen = True
+ if self._reasoning_seen and not self._content_started and ret.normal_text:
+ ret.normal_text = ret.normal_text.lstrip("\n")
+ if ret.normal_text:
+ self._content_started = True
+ return ret
+
+
class MiniMaxM3Detector(BaseReasoningFormatDetector):
"""MiniMax-M3 detector. Format: ()*(.*).
@@ -2123,6 +2188,7 @@ class ReasoningParser:
"step3p5": DeepSeekR1Detector,
"mistral": MistralDetector,
"nemotron_3": Nemotron3Detector,
+ "granite_thinking_parser": GraniteThinkingDetector,
"interns1": Qwen3Detector,
"gemma4": Gemma4Detector,
"inkling": InklingDetector,
@@ -2177,6 +2243,10 @@ class ReasoningParser:
if chat_template_kwargs.get("force_nonempty_content") is True:
kwargs["force_nonempty_content"] = True
+ if model_type.lower() == "granite_thinking_parser":
+ if chat_template_kwargs.get("enable_thinking") is False:
+ kwargs["force_nonempty_content"] = True
+
if model_type.lower() == "k2_horizon":
# Template kwargs are the final values passed to Jinja and therefore
# take precedence over the convenience fields on API requests.
diff --git a/python/sglang/srt/parser/template_detection.py b/python/sglang/srt/parser/template_detection.py
index 0d9faa454..9dda09563 100644
--- a/python/sglang/srt/parser/template_detection.py
+++ b/python/sglang/srt/parser/template_detection.py
@@ -314,6 +314,18 @@ def _is_k2_v3(ctx):
)
+def _is_granite_thinking_parser(ctx):
+ # Nemotron-3 templates share the same is present, force_nonempty_content does NOT swap
+ even if content is empty after lstrip. Matches HF plugin behavior."""
+ detector = GraniteThinkingDetector(force_nonempty_content=True)
+ text = "reasoning\n\n"
+ result = detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "reasoning")
+ self.assertEqual(result.normal_text, "")
+
+ def test_force_nonempty_swaps_when_text_ends_at_think_end(self):
+ """Content absent right after (e.g. max_tokens cut there) swaps
+ like the truncated case; newline-only content still does not."""
+ detector = GraniteThinkingDetector(force_nonempty_content=True)
+ result = detector.detect_and_parse("reasoning")
+ self.assertEqual(result.reasoning_text, "")
+ self.assertEqual(result.normal_text, "reasoning")
+
+ def test_force_nonempty_content_truncated_reasoning(self):
+ detector = GraniteThinkingDetector(force_nonempty_content=True)
+ text = "truncated reasoning"
+ result = detector.detect_and_parse(text)
+ self.assertEqual(result.normal_text, "truncated reasoning")
+ self.assertEqual(result.reasoning_text, "")
+
+ def test_plain_text_no_think_tags(self):
+ text = "Hello"
+ result = self.detector.detect_and_parse(text)
+ self.assertEqual(result.normal_text, "Hello")
+ self.assertEqual(result.reasoning_text, "")
+
+ def test_tool_interrupt(self):
+ text = "reasoningget_weather"
+ result = self.detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "reasoning")
+ self.assertEqual(result.normal_text, "get_weather")
+
+ def test_multiline_reasoning_and_content(self):
+ text = "line1\nline2\nresult1\nresult2"
+ result = self.detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "line1\nline2")
+ self.assertEqual(result.normal_text, "result1\nresult2")
+
+ def test_streaming_newlines_preserved_after_content_starts(self):
+ self.detector.parse_streaming_increment("")
+ self.detector.parse_streaming_increment("r")
+ self.detector.parse_streaming_increment("")
+ self.detector.parse_streaming_increment("\n")
+ self.detector.parse_streaming_increment("Hello")
+ result = self.detector.parse_streaming_increment("\nworld")
+ self.assertEqual(result.normal_text, "\nworld")
+
+ def test_streaming_no_strip_without_reasoning(self):
+ result = self.detector.parse_streaming_increment("\nHello")
+ self.assertEqual(result.normal_text, "\nHello")
+
+ def test_streaming_result_is_chunking_independent(self):
+ # The empty think block only trips stripped_think_start evidence:
+ # reasoning text and pre/post _in_reasoning are all empty/False there.
+ for text, exp_r, exp_c in (
+ ("r\nHello", "r", "Hello"),
+ ("\nHello", "", "Hello"),
+ ):
+ for stream_reasoning in (True, False):
+ for chunks in (
+ [text],
+ [text[: text.index("") + len("")], "\nHello"],
+ [
+ "",
+ text[len("") : text.index("")],
+ "",
+ "\nHello",
+ ],
+ list(text),
+ ):
+ with self.subTest(
+ text=text, stream_reasoning=stream_reasoning, chunks=chunks
+ ):
+ detector = GraniteThinkingDetector(
+ stream_reasoning=stream_reasoning
+ )
+ all_r = all_c = ""
+ for chunk in chunks:
+ ret = detector.parse_streaming_increment(chunk)
+ all_r += ret.reasoning_text
+ all_c += ret.normal_text
+ end = detector.finish()
+ all_r += end.reasoning_text
+ all_c += end.normal_text
+ self.assertEqual(all_r, exp_r)
+ self.assertEqual(all_c, exp_c)
+
+ def test_reasoning_parser_integration(self):
+ parser = ReasoningParser("granite_thinking_parser")
+ self.assertIsInstance(parser.detector, GraniteThinkingDetector)
+ reasoning, normal = parser.parse_non_stream(
+ "thinking\nThe answer"
+ )
+ self.assertEqual(reasoning, "thinking")
+ self.assertEqual(normal, "The answer")
+
+ def test_enable_thinking_false_swaps_truncated_reasoning(self):
+ from sglang.srt.entrypoints.openai.protocol import ChatCompletionRequest
+
+ request = ChatCompletionRequest(
+ model="granite-4.2-30b",
+ messages=[{"role": "user", "content": "hi"}],
+ chat_template_kwargs={"enable_thinking": False},
+ )
+ parser = ReasoningParser("granite_thinking_parser", request=request)
+ reasoning, normal = parser.parse_non_stream("truncated")
+ self.assertEqual(reasoning, "")
+ self.assertEqual(normal, "truncated")
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/test/registered/unit/parser/test_template_manager.py b/test/registered/unit/parser/test_template_manager.py
index 5bd6fc59d..39d844e41 100644
--- a/test/registered/unit/parser/test_template_manager.py
+++ b/test/registered/unit/parser/test_template_manager.py
@@ -278,6 +278,38 @@ class TestTemplateManagerReasoningDetection(unittest.TestCase):
)
self.assertEqual(parser, "nemotron_3")
+ def test_nemotron_with_shared_parameter_block_not_misclassified_as_granite(self):
+ # Granite 4.2 and Nemotron-3 templates share the same
+ # / tool-call instruction block; without
+ # a Granite-only signature (defer_loading) this must stay nemotron_3.
+ template = """
+ {% set enable_thinking = enable_thinking if enable_thinking is defined else True %}
+ {% set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %}
+ {{- '\\n\\n\\nvalue_1\\n\\n\\n' }}
+ """
+ _, config, parser = self._detect(template, ["<|endoftext|>"])
+
+ self.assertEqual(
+ config,
+ ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True),
+ )
+ self.assertEqual(parser, "nemotron_3")
+
+ def test_granite_detected_via_defer_loading_signature(self):
+ template = """
+ {% set enable_thinking = enable_thinking if enable_thinking is defined else True %}
+ {% set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %}
+ {%- if tool.defer_loading is not defined or not tool.defer_loading %}{%- endif %}
+ {{- '\\n\\n\\nvalue_1\\n\\n\\n' }}
+ """
+ _, config, parser = self._detect(template, [])
+
+ self.assertEqual(
+ config,
+ ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True),
+ )
+ self.assertEqual(parser, "granite_thinking_parser")
+
def test_minimax_uses_template_signature_without_toggle_config(self):
template = """
{%- set toolcall_begin_token = '' -%}