diff --git a/python/sglang/srt/parser/reasoning_parser.py b/python/sglang/srt/parser/reasoning_parser.py
index cd346625a..00c26196b 100644
--- a/python/sglang/srt/parser/reasoning_parser.py
+++ b/python/sglang/srt/parser/reasoning_parser.py
@@ -25,11 +25,13 @@ class BaseReasoningFormatDetector:
think_end_token: str,
force_reasoning: bool = False,
stream_reasoning: bool = True,
+ tool_start_token: Optional[str] = None,
continue_final_message: bool = False,
previous_content: str = "",
):
self.think_start_token = think_start_token
self.think_end_token = think_end_token
+ self.tool_start_token = tool_start_token
self._in_reasoning = force_reasoning
self.stream_reasoning = stream_reasoning
@@ -66,7 +68,21 @@ class BaseReasoningFormatDetector:
self.think_end_token not in processed_text
and self.think_end_token not in self.previous_content
):
- # Assume reasoning was truncated before `` token
+ # Check for tool_start_token interruption
+ if (
+ in_reasoning
+ and self.tool_start_token is not None
+ and self.tool_start_token in processed_text
+ ):
+ # Find the first occurrence of tool_start_token and split there
+ tool_idx = processed_text.find(self.tool_start_token)
+ reasoning_text = processed_text[:tool_idx].strip()
+ # Preserve tool_start_token in normal text
+ normal_text = processed_text[tool_idx:]
+ return StreamingParseResult(
+ normal_text=normal_text, reasoning_text=reasoning_text
+ )
+ # Assume reasoning was truncated before end token
return StreamingParseResult(reasoning_text=processed_text)
# Extract reasoning content
@@ -96,9 +112,12 @@ class BaseReasoningFormatDetector:
current_text = self._buffer
# If the current text is a prefix of the think token, keep buffering
+ tokens_to_check = [self.think_start_token, self.think_end_token]
+ if self.tool_start_token:
+ tokens_to_check.append(self.tool_start_token)
if any(
token.startswith(current_text) and token != current_text
- for token in [self.think_start_token, self.think_end_token]
+ for token in tokens_to_check
):
return StreamingParseResult()
@@ -124,6 +143,17 @@ class BaseReasoningFormatDetector:
# Continue with reasoning content
if self._in_reasoning:
+ # Check for tool_start_token interruption
+ if self.tool_start_token and self.tool_start_token in current_text:
+ tool_idx = current_text.find(self.tool_start_token)
+ reasoning_text = current_text[:tool_idx]
+ # Preserve tool_start_token in normal text
+ normal_text = current_text[tool_idx:]
+ self._buffer = ""
+ self._in_reasoning = False
+ return StreamingParseResult(
+ normal_text=normal_text, reasoning_text=reasoning_text
+ )
if self.stream_reasoning:
# Stream the content immediately
self._buffer = ""
@@ -238,6 +268,29 @@ class KimiDetector(BaseReasoningFormatDetector):
)
+class Glm45Detector(BaseReasoningFormatDetector):
+ """
+ Detector for GLM-4.5 models.
+ Assumes reasoning format:
+ ()*(.*)
+
+ GLM-4.5 uses `` as the tool start token to switch from reasoning mode to normal mode.
+
+ Args:
+ stream_reasoning (bool): If False, accumulates reasoning content until the end tag.
+ If True, streams reasoning content as it arrives.
+ """
+
+ def __init__(self, stream_reasoning: bool = True, force_reasoning: bool = False):
+ super().__init__(
+ "",
+ "",
+ force_reasoning=force_reasoning,
+ stream_reasoning=stream_reasoning,
+ tool_start_token="",
+ )
+
+
class GptOssDetector(BaseReasoningFormatDetector):
"""
Detector for T4-style reasoning format (GPT-OSS), using the HarmonyParser.
@@ -375,7 +428,7 @@ class ReasoningParser:
DetectorMap: Dict[str, Type[BaseReasoningFormatDetector]] = {
"deepseek-r1": DeepSeekR1Detector,
"deepseek-v3": Qwen3Detector,
- "glm45": Qwen3Detector,
+ "glm45": Glm45Detector,
"gpt-oss": GptOssDetector,
"kimi": KimiDetector,
"kimi_k2": Qwen3Detector,
diff --git a/test/registered/parser/test_reasoning_parser.py b/test/registered/parser/test_reasoning_parser.py
index 5c28066c7..b76f85342 100644
--- a/test/registered/parser/test_reasoning_parser.py
+++ b/test/registered/parser/test_reasoning_parser.py
@@ -3,6 +3,7 @@ import unittest
from sglang.srt.parser.reasoning_parser import (
BaseReasoningFormatDetector,
DeepSeekR1Detector,
+ Glm45Detector,
KimiDetector,
Qwen3Detector,
ReasoningParser,
@@ -313,6 +314,158 @@ class TestKimiDetector(CustomTestCase):
self.assertEqual(result.normal_text, "answer")
+class TestGlm45Detector(CustomTestCase):
+ """Test cases for GLM45 detector with tool interruption support."""
+
+ def setUp(self):
+ self.detector = Glm45Detector()
+
+ def test_init(self):
+ """Test Glm45Detector initialization."""
+ self.assertEqual(self.detector.think_start_token, "")
+ self.assertEqual(self.detector.think_end_token, "")
+ self.assertEqual(self.detector.tool_start_token, "")
+ self.assertFalse(self.detector._in_reasoning)
+ self.assertTrue(self.detector.stream_reasoning)
+
+ def test_detect_and_parse_normal_reasoning(self):
+ """Test parsing normal reasoning block without tool interruption."""
+ text = "Let me think about this step by stepThe answer is 42."
+ result = self.detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "Let me think about this step by step")
+ self.assertEqual(result.normal_text, "The answer is 42.")
+
+ def test_detect_and_parse_tool_interrupt(self):
+ """
+ Test parsing with tool interruption.
+
+ GLM45 can interrupt reasoning with tool token () without closing .
+ Should split at the first occurrence of tool_start_token using find().
+ """
+ text = "I need to thinktool call data"
+ result = self.detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "I need to think")
+ self.assertEqual(result.normal_text, "tool call data")
+
+ def test_detect_and_parse_multiple_tool_calls_find(self):
+ """
+ Test that find() finds the FIRST occurrence of tool_start_token.
+
+ If multiple tool calls exist in buffer, should split at the first one.
+ """
+ text = "thinkingfirst toolsecond toolfinal tool"
+ result = self.detector.detect_and_parse(text)
+ # Should split at the first
+ self.assertEqual(result.reasoning_text, "thinking")
+ self.assertEqual(
+ result.normal_text,
+ "first toolsecond toolfinal tool",
+ )
+
+ def test_detect_and_parse_truncated_reasoning(self):
+ """
+ Test truncated reasoning without tool or end tag.
+
+ Should return all content as reasoning_text.
+ """
+ text = "This is incomplete"
+ result = self.detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "This is incomplete")
+ self.assertEqual(result.normal_text, "")
+
+ def test_detect_and_parse_normal_text_only(self):
+ """Test parsing text without reasoning block."""
+ text = "Just the answer without any reasoning."
+ result = self.detector.detect_and_parse(text)
+ self.assertEqual(result.normal_text, text)
+ self.assertEqual(result.reasoning_text, "")
+
+ def test_streaming_normal_flow(self):
+ """Test streaming with normal reasoning flow."""
+ # Start reasoning
+ result1 = self.detector.parse_streaming_increment("")
+ self.assertEqual(result1.normal_text, "")
+ self.assertEqual(result1.reasoning_text, "")
+ self.assertTrue(self.detector._in_reasoning)
+
+ # Reasoning content
+ result2 = self.detector.parse_streaming_increment("thinking...")
+ self.assertEqual(result2.normal_text, "")
+ self.assertEqual(result2.reasoning_text, "thinking...")
+
+ # End reasoning
+ result3 = self.detector.parse_streaming_increment("answer")
+ self.assertEqual(result3.normal_text, "answer")
+ self.assertEqual(result3.reasoning_text, "")
+ self.assertFalse(self.detector._in_reasoning)
+
+ def test_streaming_tool_interrupt_split_tokens(self):
+ """
+ Test streaming with tool interruption where tool token is split across chunks.
+
+ This tests the buffer prefix logic that prevents partial emission of tool token.
+ """
+ # Start reasoning
+ self.detector.parse_streaming_increment("")
+
+ # Add reasoning
+ result1 = self.detector.parse_streaming_increment("thinking")
+ self.assertEqual(result1.reasoning_text, "thinking")
+
+ # Send partial tool token (should be buffered, not emitted)
+ result2 = self.detector.parse_streaming_increment("")
+ # Tool token is in buffer, causing switch to normal mode
+ self.assertEqual(result2.reasoning_text, "")
+ self.assertEqual(result2.normal_text, "")
+ self.assertFalse(self.detector._in_reasoning)
+
+ # Send tool args
+ result3 = self.detector.parse_streaming_increment("tool args")
+ self.assertEqual(result3.reasoning_text, "")
+ self.assertEqual(result3.normal_text, "tool args")
+
+ def test_streaming_no_stream_reasoning(self):
+ """Test streaming without stream_reasoning enabled."""
+ detector = Glm45Detector(stream_reasoning=False)
+
+ # Start reasoning
+ detector.parse_streaming_increment("")
+
+ # Reasoning content is buffered and not returned yet
+ result = detector.parse_streaming_increment("thinking")
+ self.assertEqual(result.reasoning_text, "")
+ self.assertEqual(result.normal_text, "")
+
+ # Tool interruption should still work - flushes buffered reasoning
+ # Note: buffer preserves original text including tag
+ result = detector.parse_streaming_increment("tool call")
+ self.assertEqual(result.reasoning_text, "thinking")
+ self.assertEqual(result.normal_text, "tool call")
+
+ def test_streaming_empty_reasoning_with_tool(self):
+ """Test empty reasoning block followed by tool call."""
+ result1 = self.detector.parse_streaming_increment("")
+ result2 = self.detector.parse_streaming_increment("tool call")
+ self.assertEqual(result2.reasoning_text, "")
+ self.assertEqual(result2.normal_text, "tool call")
+
+ def test_forced_reasoning_mode(self):
+ """Test GLM45 with force_reasoning=True."""
+ detector = Glm45Detector(force_reasoning=True)
+
+ # Without start token, should still be in reasoning mode
+ text = "This is reasoning"
+ result = detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "This is reasoning")
+ self.assertEqual(result.normal_text, "")
+
+ # Tool interruption should work with forced reasoning
+ text = "More reasoningtool call"
+ result = detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "More reasoning")
+ self.assertEqual(result.normal_text, "tool call")
+
+
class TestReasoningParser(CustomTestCase):
def test_init_valid_model(self):
"""Test initialization with valid model types."""
@@ -325,6 +478,9 @@ class TestReasoningParser(CustomTestCase):
parser = ReasoningParser("kimi")
self.assertIsInstance(parser.detector, KimiDetector)
+ parser = ReasoningParser("glm45")
+ self.assertIsInstance(parser.detector, Glm45Detector)
+
def test_init_invalid_model(self):
"""Test initialization with invalid model type."""
with self.assertRaises(ValueError) as context:
@@ -383,6 +539,32 @@ class TestReasoningParser(CustomTestCase):
parser = ReasoningParser("qwen3", stream_reasoning=True)
self.assertTrue(parser.detector.stream_reasoning)
+ def test_glm45_tool_interruption(self):
+ """Test GLM45 tool interruption through ReasoningParser API."""
+ parser = ReasoningParser("glm45")
+
+ # Non-streaming: tool interrupt
+ reasoning, normal = parser.parse_non_stream(
+ "thinkingtool call"
+ )
+ self.assertEqual(reasoning, "thinking")
+ self.assertEqual(normal, "tool call")
+
+ # Streaming: tool interrupt
+ parser = ReasoningParser("glm45")
+ chunks = ["", "reasoning", "", "tool args"]
+ all_reasoning = ""
+ all_normal = ""
+ for chunk in chunks:
+ reasoning, normal = parser.parse_stream_chunk(chunk)
+ if reasoning:
+ all_reasoning += reasoning
+ if normal:
+ all_normal += normal
+
+ self.assertEqual(all_reasoning, "reasoning")
+ self.assertEqual(all_normal, "tool args")
+
class TestIntegrationScenarios(CustomTestCase):
"""Integration tests for realistic usage scenarios."""