mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 14:21:06 -10:00
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:
1 parent
6f507fb210
commit
86ba33845f
3 files changed
+81
-4
No files matched your search
+24
-4
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}):
|
||||
|
||||
Reference in new issue
Block a user