diff --git a/python/sglang/srt/function_call/deepseekv32_detector.py b/python/sglang/srt/function_call/deepseekv32_detector.py
index 5d4391e91..88b13d75d 100644
--- a/python/sglang/srt/function_call/deepseekv32_detector.py
+++ b/python/sglang/srt/function_call/deepseekv32_detector.py
@@ -216,7 +216,7 @@ class DeepSeekV32Detector(BaseFormatDetector):
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
"""
idx = text.find(self.bot_token)
- normal_text = text[:idx].strip() if idx != -1 else text
+ normal_text = text[:idx].removesuffix("\n\n") if idx != -1 else text
if self.bot_token not in text:
return StreamingParseResult(normal_text=normal_text, calls=[])
diff --git a/python/sglang/srt/parser/reasoning_parser.py b/python/sglang/srt/parser/reasoning_parser.py
index 6f3cf58f7..6b63da114 100644
--- a/python/sglang/srt/parser/reasoning_parser.py
+++ b/python/sglang/srt/parser/reasoning_parser.py
@@ -70,9 +70,10 @@ class BaseReasoningFormatDetector:
return StreamingParseResult(normal_text=text)
# The text is considered to be in a reasoning block.
- processed_text = text.replace(
- self.think_start_token + self.think_start_self_label, ""
- ).strip()
+ think_start_text = self.think_start_token + self.think_start_self_label
+ processed_text = text
+ while processed_text.startswith(think_start_text):
+ processed_text = processed_text[len(think_start_text) :]
if (
self.think_end_token not in processed_text
@@ -86,7 +87,7 @@ class BaseReasoningFormatDetector:
):
# 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()
+ reasoning_text = processed_text[:tool_idx]
# Preserve tool_start_token in normal text
normal_text = processed_text[tool_idx:]
return StreamingParseResult(
@@ -99,7 +100,7 @@ class BaseReasoningFormatDetector:
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].strip()
+ normal_text = splits[1]
return StreamingParseResult(
normal_text=normal_text, reasoning_text=reasoning_text
@@ -150,7 +151,7 @@ class BaseReasoningFormatDetector:
normal_text = current_text[end_idx + len(self.think_end_token) :]
return StreamingParseResult(
- normal_text=normal_text, reasoning_text=reasoning_text.rstrip()
+ normal_text=normal_text, reasoning_text=reasoning_text
)
# Continue with reasoning content
diff --git a/test/registered/unit/entrypoints/openai/test_serving_chat.py b/test/registered/unit/entrypoints/openai/test_serving_chat.py
index dbf4ed3f8..23798f6f5 100644
--- a/test/registered/unit/entrypoints/openai/test_serving_chat.py
+++ b/test/registered/unit/entrypoints/openai/test_serving_chat.py
@@ -1228,6 +1228,37 @@ class ServingChatTestCase(unittest.TestCase):
req.reasoning_effort = effort
self.assertEqual(chat._get_reasoning_from_request(req), expected)
+ def test_non_stream_reasoning_response_preserves_payload_whitespace(self):
+ self.chat.reasoning_parser = "qwen3"
+ self.template_manager.force_reasoning = False
+
+ req = ChatCompletionRequest(
+ model="x",
+ messages=[{"role": "user", "content": "Hi?"}],
+ stream=False,
+ separate_reasoning=True,
+ )
+ ret = [
+ {
+ "text": "\nLet me think\n\n\nThe answer is 42.\n",
+ "meta_info": {
+ "id": "chatcmpl-test",
+ "prompt_tokens": 5,
+ "completion_tokens": 8,
+ "cached_tokens": 0,
+ "finish_reason": {"type": "stop", "matched": None},
+ "weight_version": "test",
+ },
+ "index": 0,
+ }
+ ]
+
+ response = self.chat._build_chat_response(req, ret, created=123)
+
+ message = response.choices[0].message
+ self.assertEqual(message.reasoning_content, "\nLet me think\n")
+ self.assertEqual(message.content, "\n\nThe answer is 42.\n")
+
# ------------- reasoning config tests -------------
def test_get_reasoning_from_request_default_true_toggle(self):
self.tm.server_args.reasoning_parser = "qwen3"
diff --git a/test/registered/unit/parser/test_reasoning_parser.py b/test/registered/unit/parser/test_reasoning_parser.py
index 3d9ea07f7..69d97e613 100644
--- a/test/registered/unit/parser/test_reasoning_parser.py
+++ b/test/registered/unit/parser/test_reasoning_parser.py
@@ -861,6 +861,37 @@ class TestReasoningParser(CustomTestCase):
self.assertEqual(reasoning, "Let me think")
self.assertEqual(normal, "The answer is 42.")
+ def test_parse_non_stream_preserves_payload_whitespace(self):
+ """Non-streaming parsing must not rewrite text inside or after reasoning."""
+ parser = ReasoningParser("qwen3")
+ reasoning, normal = parser.parse_non_stream(
+ "\nLet me think\n\n\nThe answer is 42.\n"
+ )
+ self.assertEqual(reasoning, "\nLet me think\n")
+ self.assertEqual(normal, "\n\nThe answer is 42.\n")
+
+ def test_parse_non_stream_strips_repeated_leading_start_tokens(self):
+ """Repeated leading start tokens are markers, not reasoning payload."""
+ parser = ReasoningParser("qwen3")
+ reasoning, normal = parser.parse_non_stream(
+ "Let me thinkThe answer is 42."
+ )
+ self.assertEqual(reasoning, "Let me think")
+ self.assertEqual(normal, "The answer is 42.")
+
+ def test_parse_stream_chunk_preserves_payload_whitespace(self):
+ """Streaming parsing preserves the same generated payload whitespace."""
+ parser = ReasoningParser("qwen3")
+ reasoning, normal = parser.parse_stream_chunk("")
+ self.assertEqual(reasoning, "")
+ self.assertEqual(normal, "")
+
+ reasoning, normal = parser.parse_stream_chunk(
+ "\nLet me think\n\n\nThe answer is 42.\n"
+ )
+ self.assertEqual(reasoning, "\nLet me think\n")
+ self.assertEqual(normal, "\n\nThe answer is 42.\n")
+
def test_parse_stream_chunk(self):
"""Test streaming chunk parsing."""
parser = ReasoningParser("qwen3")