mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
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:
1 parent
624070c5df
commit
0d613031a9
4 files changed
+100
-1
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user