diff --git a/python/sglang/srt/entrypoints/openai/serving_chat.py b/python/sglang/srt/entrypoints/openai/serving_chat.py
index 78a602136..9ea736a28 100644
--- a/python/sglang/srt/entrypoints/openai/serving_chat.py
+++ b/python/sglang/srt/entrypoints/openai/serving_chat.py
@@ -722,9 +722,28 @@ class OpenAIServingChat(OpenAIServingBase):
if "arguments" in item["function"] and isinstance(
item["function"]["arguments"], str
):
- item["function"]["arguments"] = orjson.loads(
- item["function"]["arguments"]
- )
+ _raw_args = item["function"]["arguments"]
+ try:
+ item["function"]["arguments"] = orjson.loads(_raw_args)
+ except orjson.JSONDecodeError:
+ # Tolerate a malformed historical tool-call arguments
+ # string (e.g. a valid JSON document followed by
+ # trailing content) instead of 400-ing the whole
+ # multi-turn request: salvage the leading JSON
+ # document if present, else keep the raw string.
+ try:
+ item["function"]["arguments"] = (
+ json.JSONDecoder().raw_decode(_raw_args.lstrip())[
+ 0
+ ]
+ )
+ except ValueError:
+ logger.warning(
+ "Keeping unparseable tool_call arguments as a "
+ "raw string (len=%d) in an assistant history "
+ "message",
+ len(_raw_args),
+ )
openai_compatible_messages.append(processed_msg)
@@ -765,9 +784,10 @@ class OpenAIServingChat(OpenAIServingBase):
return_dict=False,
**extra_template_kwargs,
)
- except jinja2.TemplateError as template_error:
- # Template errors (e.g., from raise_exception in Jinja templates)
- # should be treated as client errors (400 BadRequest)
+ except (jinja2.TemplateError, TypeError) as template_error:
+ # Template errors (e.g. raise_exception in Jinja templates) and
+ # TypeError (e.g. the tojson filter on a Jinja2 Undefined variable)
+ # should be treated as client errors (400 BadRequest).
raise ValueError(str(template_error)) from template_error
# Append assistant prefix if continue_final_message is enabled
diff --git a/python/sglang/srt/parser/reasoning_parser.py b/python/sglang/srt/parser/reasoning_parser.py
index a6867c9f8..ba7f72f9b 100644
--- a/python/sglang/srt/parser/reasoning_parser.py
+++ b/python/sglang/srt/parser/reasoning_parser.py
@@ -62,7 +62,13 @@ 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, "").strip()
+ # Strip only LEADING think-start marker tokens and preserve the rest of the
+ # generated text verbatim: a global replace would delete a think-start token
+ # that legitimately appears inside the reasoning content, and .strip() would
+ # rewrite model-generated whitespace (newlines/indentation).
+ processed_text = text
+ while processed_text.startswith(self.think_start_token):
+ processed_text = processed_text[len(self.think_start_token) :]
if (
self.think_end_token not in processed_text
@@ -76,7 +82,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(
@@ -89,7 +95,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
@@ -138,7 +144,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/parser/test_reasoning_parser.py b/test/registered/unit/parser/test_reasoning_parser.py
index 607a6ca52..cb2ec43a5 100644
--- a/test/registered/unit/parser/test_reasoning_parser.py
+++ b/test/registered/unit/parser/test_reasoning_parser.py
@@ -427,6 +427,40 @@ class TestGlm45Detector(CustomTestCase):
self.assertEqual(result.normal_text, text)
self.assertEqual(result.reasoning_text, "")
+ def test_detect_and_parse_preserves_whitespace(self):
+ """Reasoning + normal text must keep model-generated whitespace (newlines,
+ indentation); the parser must not rewrite the payload."""
+ text = "\n Let me think\n\n\nThe answer is 42.\n"
+ result = self.detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "\n Let me think\n")
+ self.assertEqual(result.normal_text, "\n\nThe answer is 42.\n")
+
+ def test_detect_and_parse_keeps_think_token_inside_content(self):
+ """A think-start token appearing INSIDE the reasoning content is real
+ content, not a marker: only LEADING markers are stripped (a global
+ replace would corrupt the reasoning)."""
+ text = "quote the tag literallyanswer"
+ result = self.detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "quote the tag literally")
+ self.assertEqual(result.normal_text, "answer")
+
+ def test_detect_and_parse_strips_repeated_leading_think_tokens(self):
+ """Repeated leading think-start tokens are markers, not payload."""
+ text = "Let me thinkThe answer is 42."
+ result = self.detector.detect_and_parse(text)
+ self.assertEqual(result.reasoning_text, "Let me think")
+ self.assertEqual(result.normal_text, "The answer is 42.")
+
+ def test_streaming_preserves_reasoning_trailing_whitespace(self):
+ """Streaming reasoning text must preserve trailing whitespace before the
+ end token (no rstrip)."""
+ self.detector.parse_streaming_increment("")
+ result = self.detector.parse_streaming_increment(
+ "reasoning \nanswer"
+ )
+ self.assertEqual(result.reasoning_text, "reasoning \n")
+ self.assertEqual(result.normal_text, "answer")
+
def test_streaming_normal_flow(self):
"""Test streaming with normal reasoning flow."""
# Start reasoning