From 34bfe33cdffcf7f81e8e637189ec13cf25117257 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Fri, 28 Aug 2026 02:31:42 +0200 Subject: [PATCH] Send empty assistant content when DeepSeek thinks without calling a tool. A thinking-only turn was replayed as content=null with no tool_calls, which the API rejects with 400 and skipped IC review. Co-authored-by: Cursor --- backend/services/llm/deepseek_provider.py | 7 +++++- tests/test_deepseek_provider.py | 27 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/backend/services/llm/deepseek_provider.py b/backend/services/llm/deepseek_provider.py index aa3d32b..80026f4 100644 --- a/backend/services/llm/deepseek_provider.py +++ b/backend/services/llm/deepseek_provider.py @@ -111,8 +111,11 @@ 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) - msg["content"] = text if text else None + # DeepSeek 400s with "content or tool_calls must be set" when + # thinking-mode returns reasoning only (content=null, no tools). + # Tool turns may keep content=null; text-only turns need "". if tool_calls: + msg["content"] = text if text else None msg["tool_calls"] = [ { "id": tc.id, @@ -124,6 +127,8 @@ def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]: } for tc in tool_calls ] + else: + msg["content"] = text 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 026d8b7..831721b 100644 --- a/tests/test_deepseek_provider.py +++ b/tests/test_deepseek_provider.py @@ -72,6 +72,33 @@ def test_messages_to_openai_pdf_becomes_text(sample_pdf: Path): assert "Extract the pin table" in blob +def test_thinking_only_assistant_sends_empty_content(): + """Thinking with no visible text and no tools must still set content.""" + out = messages_to_openai( + [Message("assistant", [ + TextBlock("", reasoning_content="Need to inspect pin 3 first."), + ])], + vision=False, + ) + assert out[0]["role"] == "assistant" + assert out[0]["content"] == "" + assert out[0]["reasoning_content"] == "Need to inspect pin 3 first." + assert "tool_calls" not in out[0] + + +def test_tool_call_assistant_may_have_null_content(): + out = messages_to_openai( + [Message("assistant", [ + ToolCall(id="c1", name="get_pintable", input={"ref": "U2"}, + reasoning_content="look up pins"), + ])], + vision=False, + ) + assert out[0]["content"] is None + assert out[0]["tool_calls"][0]["function"]["name"] == "get_pintable" + assert out[0]["reasoning_content"] == "look up pins" + + def test_messages_to_openai_tool_roundtrip(): messages = [ Message("assistant", [