diff --git a/backend/services/llm/deepseek_provider.py b/backend/services/llm/deepseek_provider.py index b81efaa..927a1e9 100644 --- a/backend/services/llm/deepseek_provider.py +++ b/backend/services/llm/deepseek_provider.py @@ -39,10 +39,6 @@ from backend.services.llm.types import ( log = logging.getLogger(__name__) _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: @@ -103,20 +99,23 @@ def _user_content_parts(blocks: list[ContentBlock], *, vision: bool) -> list[dic 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] = [] changed = False for m in messages: if m.get("role") != "assistant": out.append(m) continue - tools = m.get("tool_calls") or [] content = m.get("content") - if tools or (isinstance(content, str) and content != ""): + if isinstance(content, str): out.append(m) continue fixed = dict(m) - fixed["content"] = _EMPTY_ASSISTANT_CONTENT + fixed["content"] = content if isinstance(content, str) else "" out.append(fixed) changed = True 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)] msg: dict[str, Any] = {"role": "assistant"} 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: - msg["content"] = text if text else None msg["tool_calls"] = [ { "id": tc.id, @@ -149,8 +150,6 @@ def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]: } for tc in tool_calls ] - else: - msg["content"] = text or _EMPTY_ASSISTANT_CONTENT reasoning = _reasoning_from_blocks(m.content) if reasoning: msg["reasoning_content"] = reasoning diff --git a/tests/test_deepseek_provider.py b/tests/test_deepseek_provider.py index 0214643..95c157d 100644 --- a/tests/test_deepseek_provider.py +++ b/tests/test_deepseek_provider.py @@ -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.deepseek_provider import ( _is_vision_model, + _repair_assistant_messages, _to_openai_tool, _to_openai_tool_choice, 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 -def test_thinking_only_assistant_sends_placeholder_content(): - """Empty string is treated as unset; replay a non-empty placeholder.""" - from backend.services.llm.deepseek_provider import _EMPTY_ASSISTANT_CONTENT - +def test_thinking_only_assistant_sends_empty_string_content(): + """DeepSeek accepts content=\"\" and rejects content=null / omitted.""" out = messages_to_openai( [Message("assistant", [ TextBlock("", reasoning_content="Need to inspect pin 3 first."), @@ -83,20 +82,18 @@ def test_thinking_only_assistant_sends_placeholder_content(): vision=False, ) 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 "tool_calls" not in out[0] 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) - 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( [Message("assistant", [ ToolCall(id="c1", name="get_pintable", input={"ref": "U2"}, @@ -104,11 +101,20 @@ def test_tool_call_assistant_may_have_null_content(): ])], 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]["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(): messages = [ Message("assistant", [