Add granite_thinking_parser reasoning parser for Granite 4.2 (#38693)

Signed-off-by: Yousaf Shah <yousaf.shah@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com>
This commit is contained in:
Yousaf
2026-09-11 07:18:35 -07:00
committed by GitHub
co-authored by Claude Xinyuan Tong Xinyuan Tong
parent 335f6aab27
commit 593c7a900d
4 changed files with 250 additions and 0 deletions
@@ -10,6 +10,7 @@ from sglang.srt.parser.reasoning_parser import (
DeepSeekV4Detector,
Gemma4Detector,
Glm45Detector,
GraniteThinkingDetector,
HunyuanDetector,
InklingDetector,
KimiDetector,
@@ -1595,5 +1596,135 @@ class TestCohereCommand4DetectorFinish(CustomTestCase):
self.assertEqual(end.reasoning_text, "")
class TestGraniteThinkingDetector(CustomTestCase):
def setUp(self):
self.detector = GraniteThinkingDetector()
def test_leading_newline_stripped(self):
text = "<think>reasoning</think>\nHello"
result = self.detector.detect_and_parse(text)
self.assertEqual(result.reasoning_text, "reasoning")
self.assertEqual(result.normal_text, "Hello")
def test_reasoning_only(self):
text = "<think>reasoning</think>"
result = self.detector.detect_and_parse(text)
self.assertEqual(result.reasoning_text, "reasoning")
self.assertEqual(result.normal_text, "")
def test_force_nonempty_no_swap_when_think_end_present(self):
"""When </think> 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 = "<think>reasoning</think>\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 </think> (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("<think>reasoning</think>")
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 = "<think>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 = "<think>reasoning<tool_call>get_weather</tool_call>"
result = self.detector.detect_and_parse(text)
self.assertEqual(result.reasoning_text, "reasoning")
self.assertEqual(result.normal_text, "<tool_call>get_weather</tool_call>")
def test_multiline_reasoning_and_content(self):
text = "<think>line1\nline2</think>\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("<think>")
self.detector.parse_streaming_increment("r")
self.detector.parse_streaming_increment("</think>")
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 (
("<think>r</think>\nHello", "r", "Hello"),
("<think></think>\nHello", "", "Hello"),
):
for stream_reasoning in (True, False):
for chunks in (
[text],
[text[: text.index("</think>") + len("</think>")], "\nHello"],
[
"<think>",
text[len("<think>") : text.index("</think>")],
"</think>",
"\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(
"<think>thinking</think>\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("<think>truncated")
self.assertEqual(reasoning, "")
self.assertEqual(normal, "truncated")
if __name__ == "__main__":
unittest.main()
@@ -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
# <function=name>/<parameter=key> 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 %}
{{- '<tool_call>\\n<function=example_function_name>\\n<parameter=example_parameter_1>\\nvalue_1\\n</parameter>\\n</function>\\n</tool_call>' }}
"""
_, 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 %}
{{- '<tool_call>\\n<function=example_function_name>\\n<parameter=example_parameter_1>\\nvalue_1\\n</parameter>\\n</function>\\n</tool_call>' }}
"""
_, 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 = '<minimax:tool_call>' -%}