Send empty-string assistant content to DeepSeek, never null.
content=null is rejected even when tool_calls is set, which 400d the U19 follow-up turn after the first graph query. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -39,10 +39,6 @@ from backend.services.llm.types import (
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_VISION_HINT = "vision"
|
_VISION_HINT = "vision"
|
||||||
# DeepSeek 400s when an assistant turn has reasoning only: content=null
|
|
||||||
# and no tool_calls. Empty string is also treated as unset, so replay a
|
|
||||||
# non-empty placeholder (the official SDK sample would send content=None).
|
|
||||||
_EMPTY_ASSISTANT_CONTENT = " "
|
|
||||||
|
|
||||||
|
|
||||||
def _is_vision_model(model: str) -> bool:
|
def _is_vision_model(model: str) -> bool:
|
||||||
@@ -103,20 +99,23 @@ def _user_content_parts(blocks: list[ContentBlock], *, vision: bool) -> list[dic
|
|||||||
|
|
||||||
|
|
||||||
def _repair_assistant_messages(messages: list[dict]) -> list[dict]:
|
def _repair_assistant_messages(messages: list[dict]) -> list[dict]:
|
||||||
"""Ensure every assistant turn has content or tool_calls (DeepSeek 400)."""
|
"""Ensure every assistant turn has a string ``content`` (DeepSeek 400).
|
||||||
|
|
||||||
|
``content: null`` / omitted content is rejected even when ``tool_calls``
|
||||||
|
is present. Empty string is accepted.
|
||||||
|
"""
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
changed = False
|
changed = False
|
||||||
for m in messages:
|
for m in messages:
|
||||||
if m.get("role") != "assistant":
|
if m.get("role") != "assistant":
|
||||||
out.append(m)
|
out.append(m)
|
||||||
continue
|
continue
|
||||||
tools = m.get("tool_calls") or []
|
|
||||||
content = m.get("content")
|
content = m.get("content")
|
||||||
if tools or (isinstance(content, str) and content != ""):
|
if isinstance(content, str):
|
||||||
out.append(m)
|
out.append(m)
|
||||||
continue
|
continue
|
||||||
fixed = dict(m)
|
fixed = dict(m)
|
||||||
fixed["content"] = _EMPTY_ASSISTANT_CONTENT
|
fixed["content"] = content if isinstance(content, str) else ""
|
||||||
out.append(fixed)
|
out.append(fixed)
|
||||||
changed = True
|
changed = True
|
||||||
return out if changed else messages
|
return out if changed else messages
|
||||||
@@ -136,8 +135,10 @@ def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]:
|
|||||||
tool_calls = [b for b in m.content if isinstance(b, ToolCall)]
|
tool_calls = [b for b in m.content if isinstance(b, ToolCall)]
|
||||||
msg: dict[str, Any] = {"role": "assistant"}
|
msg: dict[str, Any] = {"role": "assistant"}
|
||||||
text = "".join(text_parts)
|
text = "".join(text_parts)
|
||||||
|
# DeepSeek rejects content=null and a missing content key, even
|
||||||
|
# when tool_calls is set. Empty string is accepted.
|
||||||
|
msg["content"] = text
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
msg["content"] = text if text else None
|
|
||||||
msg["tool_calls"] = [
|
msg["tool_calls"] = [
|
||||||
{
|
{
|
||||||
"id": tc.id,
|
"id": tc.id,
|
||||||
@@ -149,8 +150,6 @@ def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]:
|
|||||||
}
|
}
|
||||||
for tc in tool_calls
|
for tc in tool_calls
|
||||||
]
|
]
|
||||||
else:
|
|
||||||
msg["content"] = text or _EMPTY_ASSISTANT_CONTENT
|
|
||||||
reasoning = _reasoning_from_blocks(m.content)
|
reasoning = _reasoning_from_blocks(m.content)
|
||||||
if reasoning:
|
if reasoning:
|
||||||
msg["reasoning_content"] = reasoning
|
msg["reasoning_content"] = reasoning
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from backend.config import settings
|
|||||||
from backend.services.llm.pdf_ingest import extract_pdf_text, make_text_pdf
|
from backend.services.llm.pdf_ingest import extract_pdf_text, make_text_pdf
|
||||||
from backend.services.llm.deepseek_provider import (
|
from backend.services.llm.deepseek_provider import (
|
||||||
_is_vision_model,
|
_is_vision_model,
|
||||||
|
_repair_assistant_messages,
|
||||||
_to_openai_tool,
|
_to_openai_tool,
|
||||||
_to_openai_tool_choice,
|
_to_openai_tool_choice,
|
||||||
completion_from_openai,
|
completion_from_openai,
|
||||||
@@ -72,10 +73,8 @@ def test_messages_to_openai_pdf_becomes_text(sample_pdf: Path):
|
|||||||
assert "Extract the pin table" in blob
|
assert "Extract the pin table" in blob
|
||||||
|
|
||||||
|
|
||||||
def test_thinking_only_assistant_sends_placeholder_content():
|
def test_thinking_only_assistant_sends_empty_string_content():
|
||||||
"""Empty string is treated as unset; replay a non-empty placeholder."""
|
"""DeepSeek accepts content=\"\" and rejects content=null / omitted."""
|
||||||
from backend.services.llm.deepseek_provider import _EMPTY_ASSISTANT_CONTENT
|
|
||||||
|
|
||||||
out = messages_to_openai(
|
out = messages_to_openai(
|
||||||
[Message("assistant", [
|
[Message("assistant", [
|
||||||
TextBlock("", reasoning_content="Need to inspect pin 3 first."),
|
TextBlock("", reasoning_content="Need to inspect pin 3 first."),
|
||||||
@@ -83,20 +82,18 @@ def test_thinking_only_assistant_sends_placeholder_content():
|
|||||||
vision=False,
|
vision=False,
|
||||||
)
|
)
|
||||||
assert out[0]["role"] == "assistant"
|
assert out[0]["role"] == "assistant"
|
||||||
assert out[0]["content"] == _EMPTY_ASSISTANT_CONTENT
|
assert out[0]["content"] == ""
|
||||||
assert out[0]["content"]
|
assert "content" in out[0]
|
||||||
assert out[0]["reasoning_content"] == "Need to inspect pin 3 first."
|
assert out[0]["reasoning_content"] == "Need to inspect pin 3 first."
|
||||||
assert "tool_calls" not in out[0]
|
assert "tool_calls" not in out[0]
|
||||||
|
|
||||||
|
|
||||||
def test_empty_assistant_blocks_still_set_content():
|
def test_empty_assistant_blocks_still_set_content():
|
||||||
from backend.services.llm.deepseek_provider import _EMPTY_ASSISTANT_CONTENT
|
|
||||||
|
|
||||||
out = messages_to_openai([Message("assistant", [])], vision=False)
|
out = messages_to_openai([Message("assistant", [])], vision=False)
|
||||||
assert out[0]["content"] == _EMPTY_ASSISTANT_CONTENT
|
assert out[0]["content"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_tool_call_assistant_may_have_null_content():
|
def test_tool_call_assistant_sends_empty_string_content():
|
||||||
out = messages_to_openai(
|
out = messages_to_openai(
|
||||||
[Message("assistant", [
|
[Message("assistant", [
|
||||||
ToolCall(id="c1", name="get_pintable", input={"ref": "U2"},
|
ToolCall(id="c1", name="get_pintable", input={"ref": "U2"},
|
||||||
@@ -104,11 +101,20 @@ def test_tool_call_assistant_may_have_null_content():
|
|||||||
])],
|
])],
|
||||||
vision=False,
|
vision=False,
|
||||||
)
|
)
|
||||||
assert out[0]["content"] is None
|
assert out[0]["content"] == ""
|
||||||
assert out[0]["tool_calls"][0]["function"]["name"] == "get_pintable"
|
assert out[0]["tool_calls"][0]["function"]["name"] == "get_pintable"
|
||||||
assert out[0]["reasoning_content"] == "look up pins"
|
assert out[0]["reasoning_content"] == "look up pins"
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_null_content_even_when_tool_calls_present():
|
||||||
|
repaired = _repair_assistant_messages([
|
||||||
|
{"role": "assistant", "content": None, "tool_calls": [{"id": "c1"}]},
|
||||||
|
{"role": "user", "content": "ok"},
|
||||||
|
])
|
||||||
|
assert repaired[0]["content"] == ""
|
||||||
|
assert repaired[0]["tool_calls"][0]["id"] == "c1"
|
||||||
|
|
||||||
|
|
||||||
def test_messages_to_openai_tool_roundtrip():
|
def test_messages_to_openai_tool_roundtrip():
|
||||||
messages = [
|
messages = [
|
||||||
Message("assistant", [
|
Message("assistant", [
|
||||||
|
|||||||
Reference in New Issue
Block a user