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 = '' -%}