Flush dropped reasoning at stream end when stream_reasoning=False (#32225)
This commit is contained in:
@@ -227,26 +227,36 @@ class BaseReasoningFormatDetector:
|
|||||||
|
|
||||||
return StreamingParseResult()
|
return StreamingParseResult()
|
||||||
|
|
||||||
def finish(self) -> StreamingParseResult:
|
def _strip_leading_think_start(self, text: str) -> str:
|
||||||
"""
|
|
||||||
Called once when the stream ends. If force_nonempty_content is set
|
|
||||||
and the stream ended mid-reasoning, reclassifies the accumulated
|
|
||||||
reasoning (plus any partial token still buffered) as normal text.
|
|
||||||
"""
|
|
||||||
if self._force_nonempty_content and self._in_reasoning:
|
|
||||||
# stream_reasoning=False never clears _buffer, so the opening think
|
|
||||||
# token (stripped only from the base class's local view) survives here.
|
|
||||||
buffer = self._buffer
|
|
||||||
think_start_text = self.think_start_token + self.think_start_self_label
|
think_start_text = self.think_start_token + self.think_start_self_label
|
||||||
if buffer.startswith(think_start_text):
|
if text.startswith(think_start_text):
|
||||||
buffer = buffer[len(think_start_text) :]
|
return text[len(think_start_text) :]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def finish(self) -> StreamingParseResult:
|
||||||
|
"""Flush reasoning buffered under stream_reasoning=False when the stream ends
|
||||||
|
before the end token (e.g. max_tokens cut it short), instead of dropping it.
|
||||||
|
force_nonempty_content emits it as normal_text, else as reasoning_text."""
|
||||||
|
if not self._in_reasoning:
|
||||||
|
return StreamingParseResult()
|
||||||
|
|
||||||
|
# stream_reasoning=False never clears _buffer, so the opening think token
|
||||||
|
# (stripped only from the base class's local view) survives here.
|
||||||
|
buffer = self._strip_leading_think_start(self._buffer)
|
||||||
|
self._buffer = ""
|
||||||
|
|
||||||
|
if self._force_nonempty_content:
|
||||||
normal_text = self._accumulated_reasoning + buffer
|
normal_text = self._accumulated_reasoning + buffer
|
||||||
self._accumulated_reasoning = ""
|
self._accumulated_reasoning = ""
|
||||||
self._buffer = ""
|
|
||||||
if normal_text:
|
if normal_text:
|
||||||
return StreamingParseResult(normal_text=normal_text)
|
return StreamingParseResult(normal_text=normal_text)
|
||||||
return StreamingParseResult()
|
return StreamingParseResult()
|
||||||
|
|
||||||
|
if not self.stream_reasoning and buffer:
|
||||||
|
return StreamingParseResult(reasoning_text=buffer)
|
||||||
|
|
||||||
|
return StreamingParseResult()
|
||||||
|
|
||||||
|
|
||||||
class DeepSeekR1Detector(BaseReasoningFormatDetector):
|
class DeepSeekR1Detector(BaseReasoningFormatDetector):
|
||||||
"""
|
"""
|
||||||
@@ -1401,6 +1411,21 @@ class CohereCommand4Detector(BaseReasoningFormatDetector):
|
|||||||
|
|
||||||
return StreamingParseResult()
|
return StreamingParseResult()
|
||||||
|
|
||||||
|
def finish(self) -> StreamingParseResult:
|
||||||
|
# _in_reasoning stays pinned True here (phase tracked via _reasoning_done), so
|
||||||
|
# the base finish() would misfile a truncated answer tail as reasoning.
|
||||||
|
buffer = self._buffer
|
||||||
|
self._buffer = ""
|
||||||
|
if not self._reasoning_done:
|
||||||
|
ret = StreamingParseResult(
|
||||||
|
reasoning_text=self._strip_leading_think_start(buffer)
|
||||||
|
)
|
||||||
|
elif self._saw_text_start and not self._saw_text_end:
|
||||||
|
ret = StreamingParseResult(normal_text=buffer)
|
||||||
|
else:
|
||||||
|
return StreamingParseResult()
|
||||||
|
return self._maybe_apply_force_nonempty_content(ret)
|
||||||
|
|
||||||
|
|
||||||
class ReasoningParser:
|
class ReasoningParser:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import unittest
|
|||||||
from sglang.srt.parser.reasoning_parser import (
|
from sglang.srt.parser.reasoning_parser import (
|
||||||
Apertus2509Detector,
|
Apertus2509Detector,
|
||||||
BaseReasoningFormatDetector,
|
BaseReasoningFormatDetector,
|
||||||
|
CohereCommand4Detector,
|
||||||
DeepSeekR1Detector,
|
DeepSeekR1Detector,
|
||||||
DeepSeekV4Detector,
|
DeepSeekV4Detector,
|
||||||
Gemma4Detector,
|
Gemma4Detector,
|
||||||
@@ -135,6 +136,43 @@ class TestBaseReasoningFormatDetector(CustomTestCase):
|
|||||||
self.assertEqual(result.reasoning_text, "reasoning")
|
self.assertEqual(result.reasoning_text, "reasoning")
|
||||||
self.assertEqual(result.normal_text, "normal")
|
self.assertEqual(result.normal_text, "normal")
|
||||||
|
|
||||||
|
def test_finish_flushes_truncated_reasoning_no_stream_reasoning(self):
|
||||||
|
"""Bug regression: with stream_reasoning=False the base detector buffers
|
||||||
|
the whole thinking block and only emits it on </think>. A stream cut
|
||||||
|
short (e.g. max_tokens) before </think> left the trace stuck in _buffer,
|
||||||
|
and finish() dropped it. finish() must now emit it as reasoning, with the
|
||||||
|
opening think token stripped, matching the non-streaming path."""
|
||||||
|
detector = BaseReasoningFormatDetector(
|
||||||
|
"<think>", "</think>", stream_reasoning=False
|
||||||
|
)
|
||||||
|
detector.parse_streaming_increment("<think>")
|
||||||
|
self.assertEqual(
|
||||||
|
detector.parse_streaming_increment("half a thought").reasoning_text, ""
|
||||||
|
)
|
||||||
|
end = detector.finish()
|
||||||
|
self.assertEqual(end.reasoning_text, "half a thought")
|
||||||
|
self.assertEqual(end.normal_text, "")
|
||||||
|
# State is cleared, so a second finish() is a no-op (no duplicate flush).
|
||||||
|
self.assertEqual(detector._buffer, "")
|
||||||
|
self.assertEqual(detector.finish().reasoning_text, "")
|
||||||
|
|
||||||
|
def test_finish_drops_partial_end_tag_when_streaming_reasoning(self):
|
||||||
|
"""With stream_reasoning=True the reasoning is emitted chunk by chunk, so
|
||||||
|
finish() must not re-emit. Only a partial end-tag fragment can linger in
|
||||||
|
_buffer; that fragment is an incomplete token, not content, and must be
|
||||||
|
dropped rather than surfaced as reasoning."""
|
||||||
|
detector = BaseReasoningFormatDetector(
|
||||||
|
"<think>", "</think>", stream_reasoning=True
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
detector.parse_streaming_increment("<think>thought").reasoning_text,
|
||||||
|
"thought",
|
||||||
|
)
|
||||||
|
self.assertEqual(detector.parse_streaming_increment("</thi").reasoning_text, "")
|
||||||
|
end = detector.finish()
|
||||||
|
self.assertEqual(end.reasoning_text, "")
|
||||||
|
self.assertEqual(end.normal_text, "")
|
||||||
|
|
||||||
|
|
||||||
class TestDeepSeekR1Detector(CustomTestCase):
|
class TestDeepSeekR1Detector(CustomTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -155,6 +193,22 @@ class TestDeepSeekR1Detector(CustomTestCase):
|
|||||||
self.assertEqual(result.reasoning_text, "I need to think about this.")
|
self.assertEqual(result.reasoning_text, "I need to think about this.")
|
||||||
self.assertEqual(result.normal_text, "The answer is 42.")
|
self.assertEqual(result.normal_text, "The answer is 42.")
|
||||||
|
|
||||||
|
def test_finish_flushes_truncated_forced_reasoning(self):
|
||||||
|
"""Bug regression: DeepSeek-R1 forces reasoning without a <think> start
|
||||||
|
token, so the whole output is reasoning until </think>. With
|
||||||
|
stream_reasoning=False a stream cut before </think> buffered the trace;
|
||||||
|
finish() must flush it as reasoning instead of dropping it."""
|
||||||
|
detector = DeepSeekR1Detector(stream_reasoning=False)
|
||||||
|
self.assertEqual(
|
||||||
|
detector.parse_streaming_increment(
|
||||||
|
"reasoning with no end token"
|
||||||
|
).reasoning_text,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
end = detector.finish()
|
||||||
|
self.assertEqual(end.reasoning_text, "reasoning with no end token")
|
||||||
|
self.assertEqual(end.normal_text, "")
|
||||||
|
|
||||||
|
|
||||||
class TestQwen3Detector(CustomTestCase):
|
class TestQwen3Detector(CustomTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -1277,5 +1331,37 @@ class TestPoolsideV1Registered(CustomTestCase):
|
|||||||
self.assertTrue(rp.detector.thinks_internally)
|
self.assertTrue(rp.detector.thinks_internally)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCohereCommand4DetectorFinish(CustomTestCase):
|
||||||
|
"""finish() flush for Cohere's custom streaming state machine.
|
||||||
|
|
||||||
|
This detector pins _in_reasoning True for its whole run and tracks phase via
|
||||||
|
_reasoning_done, so it overrides finish() rather than inheriting the base
|
||||||
|
one, which keys on _in_reasoning."""
|
||||||
|
|
||||||
|
def test_finish_flushes_truncated_reasoning(self):
|
||||||
|
"""Stream cut mid-thinking (stream_reasoning=False, no <|END_THINKING|>)
|
||||||
|
must flush the buffered trace as reasoning instead of dropping it."""
|
||||||
|
detector = CohereCommand4Detector(stream_reasoning=False)
|
||||||
|
self.assertEqual(
|
||||||
|
detector.parse_streaming_increment("partial thinking").reasoning_text,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
end = detector.finish()
|
||||||
|
self.assertEqual(end.reasoning_text, "partial thinking")
|
||||||
|
self.assertEqual(end.normal_text, "")
|
||||||
|
|
||||||
|
def test_finish_flushes_answer_tail_as_normal_text(self):
|
||||||
|
"""Regression guard for the base-class fix: once reasoning has closed, a
|
||||||
|
truncated answer tail (stream ended before <|END_TEXT|>) must be flushed
|
||||||
|
as normal_text. The base finish() keyed on _in_reasoning would misfile it
|
||||||
|
as reasoning because this detector never clears _in_reasoning."""
|
||||||
|
detector = CohereCommand4Detector(stream_reasoning=False)
|
||||||
|
detector.parse_streaming_increment("thinking<|END_THINKING|>")
|
||||||
|
detector.parse_streaming_increment("<|START_TEXT|>the answer")
|
||||||
|
end = detector.finish()
|
||||||
|
self.assertEqual(end.normal_text, "the answer")
|
||||||
|
self.assertEqual(end.reasoning_text, "")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user