Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 63 additions & 12 deletions python/sglang/srt/parser/reasoning_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,28 @@ def detect_and_parse(self, text: str) -> StreamingParseResult:
self._detect_and_parse_impl(text)
)

def _split_at_think_start(self, text: str) -> Tuple[str, str]:
"""Remove the opening think token wherever it first appears, and return
``(text_from_the_block, leading_normal_text)``.

The block opens at the marker, not at index 0: everything the model wrote
before it is content, and matching only at index 0 leaves the literal
marker sitting inside ``reasoning_content`` after a single leading space
or newline. Under ``force_reasoning`` the block was already opened by the
chat template, so the leading text is reasoning and only an echoed marker
is dropped -- which is what the streaming path does in both cases.
"""
think_start_text = self.think_start_token + self.think_start_self_label
start_idx = text.find(think_start_text)
if start_idx <= 0:
return text, ""

preceding = text[:start_idx]
rest = text[start_idx + len(think_start_text) :]
if self._in_reasoning:
return preceding + rest, ""
return rest, preceding

def _detect_and_parse_impl(self, text: str) -> StreamingParseResult:
in_reasoning = self._in_reasoning or self.think_start_token in text

Expand All @@ -130,7 +152,7 @@ def _detect_and_parse_impl(self, text: str) -> StreamingParseResult:

# The text is considered to be in a reasoning block.
think_start_text = self.think_start_token + self.think_start_self_label
processed_text = text
processed_text, leading_normal_text = self._split_at_think_start(text)
while processed_text.startswith(think_start_text):
processed_text = processed_text[len(think_start_text) :]

Expand All @@ -148,25 +170,29 @@ def _detect_and_parse_impl(self, text: str) -> StreamingParseResult:
tool_idx = processed_text.find(self.tool_start_token)
reasoning_text = processed_text[:tool_idx]
# Preserve tool_start_token in normal text
normal_text = processed_text[tool_idx:]
normal_text = leading_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)
return StreamingParseResult(
normal_text=leading_normal_text, reasoning_text=processed_text
)

# Extract reasoning content
if self.think_end_token in processed_text:
splits = processed_text.split(self.think_end_token, maxsplit=1)
reasoning_text = splits[0]
normal_text = splits[1]
normal_text = leading_normal_text + splits[1]

return StreamingParseResult(
normal_text=normal_text, reasoning_text=reasoning_text
)
else:
# think_end_token is in self.previous_content for continue_final_message=True case
return StreamingParseResult(normal_text=processed_text)
return StreamingParseResult(
normal_text=leading_normal_text + processed_text
)

def parse_streaming_increment(self, new_text: str) -> StreamingParseResult:
"""
Expand Down Expand Up @@ -203,8 +229,17 @@ def _parse_streaming_increment_impl(self, new_text: str) -> StreamingParseResult
return StreamingParseResult()

# Strip `<think>` token if present
leading_normal_text = ""
if not self.stripped_think_start and think_start_text in current_text:
current_text = current_text.replace(think_start_text, "", 1)
if self._in_reasoning:
# force_reasoning: the block was open before the marker, so the
# text in front of an echoed marker is reasoning, not content.
current_text = current_text.replace(think_start_text, "", 1)
else:
# The block opens at the marker; what came before it is content.
start_idx = current_text.find(think_start_text)
leading_normal_text = current_text[:start_idx]
current_text = current_text[start_idx + len(think_start_text) :]
# Write back, or stream_reasoning=False carries the token into finish().
self._buffer = current_text
self.stripped_think_start = True
Expand All @@ -218,7 +253,10 @@ def _parse_streaming_increment_impl(self, new_text: str) -> StreamingParseResult

self._buffer = ""
self._in_reasoning = False
normal_text = current_text[end_idx + len(self.think_end_token) :]
normal_text = (
leading_normal_text
+ current_text[end_idx + len(self.think_end_token) :]
)

return StreamingParseResult(
normal_text=normal_text, reasoning_text=reasoning_text
Expand All @@ -232,7 +270,7 @@ def _parse_streaming_increment_impl(self, new_text: str) -> StreamingParseResult
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:]
normal_text = leading_normal_text + current_text[tool_idx:]
self._buffer = ""
self._in_reasoning = False
return StreamingParseResult(
Expand All @@ -252,15 +290,28 @@ def _parse_streaming_increment_impl(self, new_text: str) -> StreamingParseResult
)
self._buffer = current_text[len(current_text) - holdback :]
return StreamingParseResult(
reasoning_text=current_text[: len(current_text) - holdback]
normal_text=leading_normal_text,
reasoning_text=current_text[: len(current_text) - holdback],
)
else:
return StreamingParseResult()
return StreamingParseResult(normal_text=leading_normal_text)

# If we're not in a reasoning block return as normal text
if not self._in_reasoning:
self._buffer = ""
return StreamingParseResult(normal_text=current_text)
# The prefix check above only fires when the *whole* buffer is a
# prefix of the opening token. A chunk that carries content first
# ("Sure." + "<") clears the buffer, so the rest of the marker
# arrives with nothing to attach to and the whole reasoning block
# reaches the client as raw text. Hold the partial marker back the
# same way the reasoning branch holds back the closing one.
holdback = (
self._ends_with_partial_token(current_text, think_start_text)
if not self.stripped_think_start
else 0
)
cut = len(current_text) - holdback
self._buffer = current_text[cut:]
return StreamingParseResult(normal_text=current_text[:cut])

return StreamingParseResult()

Expand Down
53 changes: 38 additions & 15 deletions test/registered/unit/parser/test_reasoning_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1055,25 +1055,48 @@ def test_normal_text_ending_in_token_prefix_survives(self):
self._feed(DeepSeekR1Detector(), text, chunk_size), expected
)

def test_text_before_think_token_is_chunk_dependent(self):
"""Accepted divergence, inherited from main: text before `<think>` lands
in reasoning or content depending on where the chunk boundary falls."""
text = "lead<think>r</think>tail"
variants = {
self._feed(Qwen3Detector(), text, chunk_size)
for chunk_size in self.CHUNK_SIZES
}
def test_text_before_think_token_is_content(self):
"""Text the model writes before `<think>` is content, whatever the
chunking. Previously this produced three different streaming splits --
including one where the whole block, markers and all, reached the client
as content -- plus a fourth from one-shot that kept the literal `<think>`
inside reasoning_content."""
self._assert_invariant(
Qwen3Detector,
"lead<think>r</think>tail",
("r", "leadtail"),
)

self.assertEqual(
variants,
{("r", "leadtail"), ("", text), ("leadr", "tail")},
def test_single_leading_newline_before_think_token(self):
"""The common shape: one character in front of the opening marker. The
one-shot path only stripped `<think>` at index 0, so the marker itself
used to be handed to the client inside reasoning_content."""
self._assert_invariant(
Qwen3Detector,
"\n<think>r</think>tail",
("r", "\ntail"),
)
# And the non-streaming path produces yet a fourth split.
one_shot = Qwen3Detector().detect_and_parse(text)
self.assertEqual(
(one_shot.reasoning_text, one_shot.normal_text), ("lead<think>r", "tail")

def test_forced_reasoning_keeps_lead_text_as_reasoning(self):
"""When the chat template already opened the block, text before an echoed
marker is reasoning -- the opposite routing from the non-forced case, and
the streaming path's behaviour all along. Only the marker is dropped."""
self._assert_invariant(
DeepSeekR1Detector,
"lead<think>r</think>tail",
("leadr", "tail"),
)

def test_content_ending_in_start_token_prefix_survives(self):
"""The holdback that recombines a split opening marker must not swallow
content that merely ends in one of its prefixes and is never completed."""
for chunk_size in self.CHUNK_SIZES:
with self.subTest(chunk_size=chunk_size):
self.assertEqual(
self._feed(Qwen3Detector(), "answer <", chunk_size),
("", "answer <"),
)

def test_dsv4_reasoning_quoting_dsml_is_chunk_dependent(self):
"""Accepted divergence: streaming ends the block at the DSML marker, while
one-shot waits to see whether a `</think>` follows. Reachable because the
Expand Down
Loading