serve: a DeepSeek turn that stops without </think> and wrote no answer returns its text as the answer (streaming ends with it as content; the non-streaming OpenAI and Anthropic replies drop the identical reasoning); tests

This commit is contained in:
Niko1221 committed 2026-10-23 23:00:05 +02:00
1 parent 6f507fb210
commit 86ba33845f
3 files changed
+81 -4

No files matched your search

+24 -4
View File
@@ -22,7 +22,7 @@ from __future__ import annotations
import re
from serve.frontend import OutputParser
from serve.frontend import Event, OutputParser
DSML = "|DSML|"
_OPEN = "<" + DSML
@@ -168,6 +168,19 @@ class DsmlOutputParser(OutputParser):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dsml = DsmlTranslator(self._on_param)
# The 2-bit sets often write the whole answer after <think> and stop (EOS) without </think>: everything would be reasoning and
# the reply empty (P2003 install test, 4 of 4 prompts). The reasoning text is kept here; finish() hands it over as the answer
# when the turn ended on a stop with no answer and no call.
self._reasoning: list[str] = []
self._answered = False
def _watch(self, evs):
for ev in evs:
if ev.kind == "reasoning":
self._reasoning.append(ev.text)
elif ev.kind in ("tool_start", "tool_call") or (ev.kind == "content" and ev.text.strip()):
self._answered = True
return evs
def _on_param(self, tool, name, is_string) -> None:
"""string="true": the value is a string whatever it looks like ("123", "true"), as the template wrote it."""
@@ -186,12 +199,19 @@ class DsmlOutputParser(OutputParser):
self.sdeclared[name] = "string"
def feed(self, delta: str):
return super().feed(self.dsml.feed(delta))
return self._watch(super().feed(self.dsml.feed(delta)))
def finish(self, reason: str | None = None):
rest = self.dsml.flush()
out = super().feed(rest) if rest else []
return out + super().finish(reason)
out = self._watch(super().feed(rest) if rest else [])
out += self._watch(super().finish(reason))
text = "".join(self._reasoning).strip()
if reason in (None, "stop") and text and not self._answered:
# no </think> and no answer: the text IS the answer. A streaming client has already seen it as reasoning and now gets
# it as content; the non-streaming collectors (server.py) drop the identical reasoning.
self._answered = True
out.append(Event("content", text))
return out
@property
def clean(self) -> bool:
+7
View File
@@ -5557,6 +5557,8 @@ def openai_collect(chunks) -> dict:
if last["choices"][0]["finish_reason"] != "tool_calls":
calls = [c for c in calls if _is_json(c["function"]["arguments"])] # a call the output ended inside (#211)
msg = {"role": "assistant", "content": "".join(content) or None}
if "".join(reasoning).strip() and "".join(reasoning).strip() == "".join(content).strip():
reasoning = [] # a turn with no </think> (serve/dsml.py): the text is the answer; it is not also reasoning
if "".join(reasoning):
msg["reasoning_content"] = "".join(reasoning)
if calls:
@@ -5695,6 +5697,11 @@ def anthropic_collect(events) -> dict:
msg["stop_reason"] = e["delta"]["stop_reason"]
msg["stop_sequence"] = e["delta"].get("stop_sequence")
msg["usage"].update(e["usage"])
for i in range(len(blocks) - 1): # a turn with no </think> (serve/dsml.py): the text is the answer; it is not also a thinking block
a, b = blocks[i], blocks[i + 1]
if a.get("type") == "thinking" and b.get("type") == "text" and a.get("thinking", "").strip() == b.get("text", "").strip():
del blocks[i]
break
msg["content"] = blocks
return msg
+50
View File
@@ -345,6 +345,56 @@ class Api(unittest.TestCase):
self.assertEqual(msg["content"].strip(), "Hello!")
self.assertEqual(r["choices"][0]["finish_reason"], "stop")
ANSWER = "17 x 23 = 391. The answer is 391."
def test_no_closing_think_tag_makes_the_text_the_answer(self):
"""The 2-bit sets write the answer after <think> and stop without </think>: it must not arrive as reasoning only (P2003)."""
for effort in (None, "low", "medium", "high"):
with self.subTest(effort=effort, api="openai"):
self.start(self.ANSWER)
status, r = self.chat(**({"reasoning_effort": effort} if effort else {}))
self.assertEqual(status, 200, r)
msg = r["choices"][0]["message"]
self.assertEqual(msg["content"].strip(), self.ANSWER)
self.assertFalse(msg.get("reasoning_content"))
self.assertEqual(r["choices"][0]["finish_reason"], "stop")
with self.subTest(effort=effort, api="anthropic"):
self.start(self.ANSWER)
status, r = self.post("/v1/messages", {"model": "x", "max_tokens": 400,
"messages": [{"role": "user", "content": "hi"}],
**({"output_config": {"effort": effort}} if effort else {})})
self.assertEqual(status, 200, r)
self.assertEqual([b["type"] for b in r["content"]], ["text"])
self.assertEqual(r["content"][0]["text"].strip(), self.ANSWER)
def test_no_closing_think_tag_streaming_ends_with_the_answer_as_content(self):
self.start(self.ANSWER)
status, raw = self.post("/v1/chat/completions", {"messages": [{"role": "user", "content": "hi"}], "max_tokens": 400,
"stream": True}, raw=True)
self.assertEqual(status, 200)
chunks = [json.loads(l[6:]) for l in raw.splitlines() if l.startswith("data: ") and l != "data: [DONE]"]
content = "".join(c["choices"][0]["delta"].get("content") or "" for c in chunks if c["choices"])
self.assertEqual(content.strip(), self.ANSWER)
status, raw = self.post("/v1/messages", {"model": "x", "max_tokens": 400, "stream": True,
"messages": [{"role": "user", "content": "hi"}]}, raw=True)
self.assertEqual(status, 200)
self.assertIn('"text_delta"', raw)
self.assertIn("391", raw)
def test_a_turn_cut_by_max_tokens_is_not_turned_into_an_answer(self):
self.start(self.ANSWER * 30)
status, r = self.chat(max_tokens=8)
msg = r["choices"][0]["message"]
self.assertEqual(r["choices"][0]["finish_reason"], "length")
self.assertFalse(msg.get("content"))
self.assertTrue(msg.get("reasoning_content"))
def test_a_normal_closed_think_is_unchanged(self):
self.start("Reasoning here.</think>The answer.")
status, r = self.chat()
msg = r["choices"][0]["message"]
self.assertEqual((msg["reasoning_content"], msg["content"].strip()), ("Reasoning here.", "The answer."))
def test_thinking_off(self):
self.start("Hello!")
for extra in ({"chat_template_kwargs": {"enable_thinking": False}}, {"reasoning_effort": "none"}):