serve: a </think> the reasoning quotes stays reasoning instead of ending it (#537)

The reasoning ended at the first </think> in the output, also when the model only
quotes the tag while it thinks (`</think>`, "</think>\n\n2" from a test). The rest
of the thinking then came back as the answer, and a tool call quoted in it could
be run.

A </think> right after a quote or backtick and followed by anything but a line
break is now reasoning text; the end of the thinking is followed by a line break
and ends it as before. Until the character after such a tag arrives, the tag is
held like a partial one, so streamed and whole outputs agree. If the turn ends
right after it, it was the end of the thinking: a forced tool call still opens
there, and the #1053 retry is not triggered, as before.
This commit is contained in:
BlueKingMuch authored and Niko1221 committed 2026-10-09 03:15:12 +02:00
1 parent 624070c5df
commit 0d613031a9
4 files changed
+100 -1

No files matched your search

+25
View File
@@ -829,6 +829,19 @@ class OutputParser:
"""The text so far leaves the next character inside a code fence or inline code."""
return bool(self.fence) or (self.ticks + self.line.count("`")) % 2 == 1
def _quoted_think_end(self, i: int) -> bool | None:
"""#537: the `</think>` at self.buf[i] is the model quoting the tag while it thinks, not the end of the
thinking: written right after a quote or backtick and followed by anything but a line break, as in `</think>`
or "</think>...". The end of the thinking is followed by a line break. None while the character after the
tag has not arrived."""
before = self.buf[i - 1] if i else self.line[-1:]
if before not in ('"', "'", "`"):
return False
after = self.buf[i + len(THINK_END):]
if not after:
return None
return after[0] not in "\r\n"
def _release(self, deliver: bool) -> list[Event]:
"""Settle the calls waiting in self.pending: events for real calls, or all of it back as reasoning text."""
out = []
@@ -1037,6 +1050,16 @@ class OutputParser:
out.append(Event("reasoning", self._track(self.buf[:len(self.buf) - keep])))
self.buf = self.buf[len(self.buf) - keep:]
return out
quoted = self._quoted_think_end(i)
if quoted is None: # held like a partial tag until the character after it is here
if i:
out.append(Event("reasoning", self._track(self.buf[:i])))
self.buf = self.buf[i:]
return out
if quoted: # #537: a quoted `</think>` is reasoning text
out.append(Event("reasoning", self._track(self.buf[:i + len(THINK_END)])))
self.buf = self.buf[i + len(THINK_END):]
continue
if i:
out.append(Event("reasoning", self._track(self.buf[:i])))
self.buf = self.buf[i + len(THINK_END):]
@@ -1199,6 +1222,8 @@ class OutputParser:
self.buf = ""
self._reset_scan()
return out
if self.state == "reasoning" and self.buf == THINK_END: # the output ends on a held `</think>`: its end
self.buf, self.state = "", "content"
if self.buf:
# an unfinished call inside the reasoning (#804) is reasoning text, never a call
kind = {"reasoning": "reasoning", "rcall": "reasoning", "content": "content"}.get(self.state, "content")
+5 -1
View File
@@ -54,7 +54,7 @@ from urllib.parse import parse_qs, urlsplit
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
sys.path.insert(0, str(ROOT)) # run as a script (run-<model>.bat) as well as a module
from serve.frontend import (ChatTemplate, Event, OutputParser, anthropic_to_messages, # noqa: E402
from serve.frontend import (THINK_END, ChatTemplate, Event, OutputParser, anthropic_to_messages, # noqa: E402
forced_call, images_of, literal_tags, mark_think_literals, openai_to_messages,
tool_choice_of, unmark_think_literals)
from serve.mcp import McpCancelled, hub_from_config # noqa: E402
@@ -3538,6 +3538,9 @@ class Service:
with self.status_lock:
trace["first_token_s"] = round(time.perf_counter() - trace["_clock"], 3)
if t in self.stop_ids:
if force and parser.state == "reasoning" and parser.buf == THINK_END:
opens = True # #537: the held </think> was the end: the call opens there
break
finish = "stop"
raw_ids.append(t)
break
@@ -3634,6 +3637,7 @@ class Service:
continue
if (self.reasoning_close_retry and thinking and finish == "stop" and not close_retried
and not answered and not wrap and not opens and parser.state == "reasoning"
and parser.buf != THINK_END # #537: not after a held </think>, the end
and (stops is None or stops.hit is None) and not cancel.is_set()):
# #1053: the model wrote its reasoning and stopped before </think>: the client would get
# an empty answer. Close the thinking once and let it answer.
+41
View File
@@ -111,6 +111,47 @@ class Parser(unittest.TestCase):
self.assertEqual(joined(p.feed("ats are nice</think>"), "reasoning"), "<tool_cats are nice")
class QuotedThinkEnd(unittest.TestCase):
"""#537: a `</think>` the model quotes while it thinks - right after a quote or backtick, with no line break after
it - is reasoning text; the end of the thinking is still a `</think>` with a line break after it."""
def test_a_quoted_tag_stays_reasoning(self):
for quote in ("in rcall, `</think>` could arrive inside the held body",
'the mock engine returns "</think>\\n\\n2", no tool call',
'```python\ntext = ("</think>\\n\\nI will use the tag now.")\n```',
"split it on '</think>' first"):
text = "plan\n" + quote + "\ndone\n</think>\n\nanswer"
for width in (1, 7, 100_000):
with self.subTest(quote=quote[:24], width=width):
evs = run(text, width)
self.assertEqual(joined(evs, "reasoning"), "plan\n" + quote + "\ndone\n")
self.assertEqual(joined(evs, "content"), "answer")
def test_a_call_quoted_after_it_is_not_run(self):
text = ('it returns "</think>\\n\\n2" here.\nThe call looks like this:\n' + CALL + " in the answer.\n"
"</think>\n\n" + CALL)
for width in (1, 7, 100_000):
with self.subTest(width=width):
evs = run(text, width)
self.assertEqual(len([e for e in evs if e.kind == "tool_call"]), 1) # the answer's own call
self.assertIn(CALL + " in the answer.", joined(evs, "reasoning"))
self.assertEqual(joined(evs, "content"), "")
def test_the_end_after_a_quote_still_ends_the_thinking(self):
for thought in ('she said "yes"', "it is `x`", "it is 'x'"):
for width in (1, 100_000):
with self.subTest(thought=thought, width=width):
evs = run(thought + "</think>\n\nanswer", width)
self.assertEqual(joined(evs, "reasoning"), thought)
self.assertEqual(joined(evs, "content"), "answer")
def test_an_output_that_ends_on_the_tag(self):
for width in (1, 100_000):
evs = run('say "</think>', width)
self.assertEqual(joined(evs, "reasoning"), 'say "')
self.assertEqual(joined(evs, "content"), "")
class OverHttp(unittest.TestCase):
def test_finish_reasons_and_block_order(self):
helper = UnfinishedToolCall("test_parser")
+29
View File
@@ -3233,6 +3233,15 @@ class ReasoningCloseRetry(unittest.TestCase):
self.assertEqual(len(self.engine.prompts), 1)
self.assertEqual(c["message"]["content"], "Fine.")
def test_a_reply_that_closed_its_thinking_right_after_a_quote_is_not_touched(self):
# #537: that </think> waits for the character after it; the end of the turn makes it the end of the thinking
self.svc.reasoning_close_retry = True
self.engine.THOUGHT = 'two plus two is "4"</think>'
c = self.chat()
self.assertEqual(len(self.engine.prompts), 1)
self.assertFalse(c["message"].get("content"))
self.assertEqual(c["message"]["reasoning_content"], 'two plus two is "4"')
class ThinkingBudget(unittest.TestCase):
"""#123: reasoning_budget_tokens (opt-in): at the budget the thinking is wrapped up and the model answers,
@@ -3467,6 +3476,26 @@ class ForcedToolChoice(unittest.TestCase):
# where the thinking ended, after the blank line the template puts before a call
self.assertEqual(second, first + self.tok.encode(CallingEngine.THOUGHT + "</think>\n\n" + opening))
def test_a_thinking_that_ends_right_after_a_quote_opens_the_call(self):
# #537: that </think> waits for the character after it; the end of the turn makes it the end of the thinking
thought, calling = 'I will search for "2+2"', self.engine.generate
def generate(ids, max_new, sampling, cancel, embeddings=None):
if self.tok.decode(ids).endswith(("<function=", "<function=search>\n")):
yield from calling(ids, max_new, sampling, cancel)
return
self.engine.prompts.append(list(ids))
yield from self.tok.encode(thought + "</think>") + self.tok.encode("<|im_end|>", parse_special=True)
self.engine.generate = generate
for stream in (False, True):
with self.subTest(stream=stream):
self.engine.prompts = []
code, b = self.openai(tool_choice="required", stream=stream)
finish, calls, reasoning = self.call_of(code, b, stream)
self.assertEqual((finish, calls, reasoning), ("tool_calls", [("search", {"q": "2+2"})], thought))
first, second = self.engine.prompts
self.assertEqual(second, first + self.tok.encode(thought + "</think>\n\n<tool_call>\n<function="))
def test_without_thinking_the_prompt_ends_with_the_opening(self):
for choice in ("required", self.NAMED):
for stream in (False, True):