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:
co-authored by
Claude
Xinyuan Tong
Xinyuan Tong
parent
335f6aab27
commit
593c7a900d
@@ -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 </think> — the Granite chat
|
||||||
|
template writes ``\\n</think>\\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__(
|
||||||
|
"<think>",
|
||||||
|
"</think>",
|
||||||
|
force_reasoning=force_reasoning,
|
||||||
|
stream_reasoning=stream_reasoning,
|
||||||
|
tool_start_token="<tool_call>",
|
||||||
|
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 </think>);
|
||||||
|
# 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):
|
class MiniMaxM3Detector(BaseReasoningFormatDetector):
|
||||||
"""MiniMax-M3 detector. Format: (<mm:think>)*(.*)</mm:think>.
|
"""MiniMax-M3 detector. Format: (<mm:think>)*(.*)</mm:think>.
|
||||||
|
|
||||||
@@ -2123,6 +2188,7 @@ class ReasoningParser:
|
|||||||
"step3p5": DeepSeekR1Detector,
|
"step3p5": DeepSeekR1Detector,
|
||||||
"mistral": MistralDetector,
|
"mistral": MistralDetector,
|
||||||
"nemotron_3": Nemotron3Detector,
|
"nemotron_3": Nemotron3Detector,
|
||||||
|
"granite_thinking_parser": GraniteThinkingDetector,
|
||||||
"interns1": Qwen3Detector,
|
"interns1": Qwen3Detector,
|
||||||
"gemma4": Gemma4Detector,
|
"gemma4": Gemma4Detector,
|
||||||
"inkling": InklingDetector,
|
"inkling": InklingDetector,
|
||||||
@@ -2177,6 +2243,10 @@ class ReasoningParser:
|
|||||||
if chat_template_kwargs.get("force_nonempty_content") is True:
|
if chat_template_kwargs.get("force_nonempty_content") is True:
|
||||||
kwargs["force_nonempty_content"] = 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":
|
if model_type.lower() == "k2_horizon":
|
||||||
# Template kwargs are the final values passed to Jinja and therefore
|
# Template kwargs are the final values passed to Jinja and therefore
|
||||||
# take precedence over the convenience fields on API requests.
|
# take precedence over the convenience fields on API requests.
|
||||||
|
|||||||
@@ -314,6 +314,18 @@ def _is_k2_v3(ctx):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_granite_thinking_parser(ctx):
|
||||||
|
# Nemotron-3 templates share the same <parameter= tool-call block, so it
|
||||||
|
# cannot discriminate; defer_loading is Granite's deferred tool loading.
|
||||||
|
return (
|
||||||
|
ctx.has_text("truncate_history_thinking")
|
||||||
|
and ctx.has_text("defer_loading")
|
||||||
|
and ctx.reasoning_config is not None
|
||||||
|
and ctx.reasoning_config.toggle_param == "enable_thinking"
|
||||||
|
and ctx.reasoning_config.default_enabled is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_nemotron_3(ctx):
|
def _is_nemotron_3(ctx):
|
||||||
return ctx.has_text("truncate_history_thinking") and (
|
return ctx.has_text("truncate_history_thinking") and (
|
||||||
ctx.reasoning_config is not None
|
ctx.reasoning_config is not None
|
||||||
@@ -502,6 +514,11 @@ REASONING_PARSER_RULES = (
|
|||||||
DetectionRule(name="mistral", value="mistral", predicate=_is_mistral),
|
DetectionRule(name="mistral", value="mistral", predicate=_is_mistral),
|
||||||
DetectionRule(name="gpt_oss", value="gpt-oss", predicate=_is_gpt_oss),
|
DetectionRule(name="gpt_oss", value="gpt-oss", predicate=_is_gpt_oss),
|
||||||
DetectionRule(name="kimi_k2", value="kimi_k2", predicate=_is_kimi_k2),
|
DetectionRule(name="kimi_k2", value="kimi_k2", predicate=_is_kimi_k2),
|
||||||
|
DetectionRule(
|
||||||
|
name="granite_thinking_parser",
|
||||||
|
value="granite_thinking_parser",
|
||||||
|
predicate=_is_granite_thinking_parser,
|
||||||
|
),
|
||||||
DetectionRule(name="nemotron_3", value="nemotron_3", predicate=_is_nemotron_3),
|
DetectionRule(name="nemotron_3", value="nemotron_3", predicate=_is_nemotron_3),
|
||||||
DetectionRule(name="glm45", value="glm45", predicate=_is_glm_family),
|
DetectionRule(name="glm45", value="glm45", predicate=_is_glm_family),
|
||||||
DetectionRule(name="hunyuan", value="hunyuan", predicate=_is_hunyuan),
|
DetectionRule(name="hunyuan", value="hunyuan", predicate=_is_hunyuan),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sglang.srt.parser.reasoning_parser import (
|
|||||||
DeepSeekV4Detector,
|
DeepSeekV4Detector,
|
||||||
Gemma4Detector,
|
Gemma4Detector,
|
||||||
Glm45Detector,
|
Glm45Detector,
|
||||||
|
GraniteThinkingDetector,
|
||||||
HunyuanDetector,
|
HunyuanDetector,
|
||||||
InklingDetector,
|
InklingDetector,
|
||||||
KimiDetector,
|
KimiDetector,
|
||||||
@@ -1595,5 +1596,135 @@ class TestCohereCommand4DetectorFinish(CustomTestCase):
|
|||||||
self.assertEqual(end.reasoning_text, "")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -278,6 +278,38 @@ class TestTemplateManagerReasoningDetection(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(parser, "nemotron_3")
|
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):
|
def test_minimax_uses_template_signature_without_toggle_config(self):
|
||||||
template = """
|
template = """
|
||||||
{%- set toolcall_begin_token = '<minimax:tool_call>' -%}
|
{%- set toolcall_begin_token = '<minimax:tool_call>' -%}
|
||||||
|
|||||||
Reference in New Issue
Block a user