From a9238875dd2eaf1f167f2d4b3921ed73a3a1fd9e Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Fri, 28 Aug 2026 08:15:07 +0200 Subject: [PATCH] Retry DeepSeek thinking-only turns so review does not 400 on U19. Empty assistant content is still treated as unset; replay a placeholder and retry once with thinking disabled. Co-authored-by: Cursor --- backend/services/llm/deepseek_provider.py | 71 ++++++++++++++++++--- tests/test_deepseek_provider.py | 76 ++++++++++++++++++++++- 2 files changed, 137 insertions(+), 10 deletions(-) diff --git a/backend/services/llm/deepseek_provider.py b/backend/services/llm/deepseek_provider.py index 80026f4..fc6a54d 100644 --- a/backend/services/llm/deepseek_provider.py +++ b/backend/services/llm/deepseek_provider.py @@ -16,7 +16,7 @@ import json import logging from typing import Any -from openai import AsyncOpenAI +from openai import APIStatusError, AsyncOpenAI from backend.config import settings from backend.services.llm.base import LLMProvider, LLMSession @@ -38,6 +38,10 @@ 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: @@ -97,6 +101,26 @@ def _user_content_parts(blocks: list[ContentBlock], *, vision: bool) -> list[dic return parts +def _repair_assistant_messages(messages: list[dict]) -> list[dict]: + """Ensure every assistant turn has content or tool_calls (DeepSeek 400).""" + 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 != ""): + out.append(m) + continue + fixed = dict(m) + fixed["content"] = _EMPTY_ASSISTANT_CONTENT + out.append(fixed) + changed = True + return out if changed else messages + + def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]: """Convert unified messages into DeepSeek/OpenAI chat messages. @@ -111,9 +135,6 @@ 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 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"] = [ @@ -128,7 +149,7 @@ def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]: for tc in tool_calls ] else: - msg["content"] = text + msg["content"] = text or _EMPTY_ASSISTANT_CONTENT reasoning = _reasoning_from_blocks(m.content) if reasoning: msg["reasoning_content"] = reasoning @@ -281,8 +302,44 @@ class DeepSeekSession(LLMSession): kwargs["tools"] = [_to_openai_tool(t) for t in tools] kwargs["tool_choice"] = _to_openai_tool_choice(tool_choice) - resp = await self._client.chat.completions.create(**kwargs) - return completion_from_openai(resp) + resp = await self._create(kwargs) + completion = completion_from_openai(resp) + # Thinking-only (no visible text, no tools) cannot be replayed on the + # next turn. Retry once with thinking off so the review loop gets a + # real content/tool_calls message instead of a 400 on the follow-up. + if ( + thinking + and not (completion.text or "").strip() + and not completion.tool_calls + ): + log.warning( + "DeepSeek thinking-only turn (no content/tool_calls); " + "retrying with thinking disabled" + ) + extra_body = { + "thinking": {"type": "disabled"}, + } + kwargs = {**kwargs, "extra_body": extra_body} + resp = await self._create(kwargs) + completion = completion_from_openai(resp) + return completion + + async def _create(self, kwargs: dict[str, Any]) -> Any: + try: + return await self._client.chat.completions.create(**kwargs) + except APIStatusError as exc: + body = str(exc) + if exc.status_code != 400 or "content or tool_calls" not in body: + raise + repaired = _repair_assistant_messages(list(kwargs["messages"])) + if repaired == kwargs["messages"]: + raise + log.warning( + "DeepSeek 400 (empty assistant content); retrying with " + "placeholder content" + ) + kwargs = {**kwargs, "messages": repaired} + return await self._client.chat.completions.create(**kwargs) async def close(self) -> None: return None diff --git a/tests/test_deepseek_provider.py b/tests/test_deepseek_provider.py index a33e29c..0214643 100644 --- a/tests/test_deepseek_provider.py +++ b/tests/test_deepseek_provider.py @@ -72,8 +72,10 @@ 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.""" +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 + out = messages_to_openai( [Message("assistant", [ TextBlock("", reasoning_content="Need to inspect pin 3 first."), @@ -81,11 +83,19 @@ def test_thinking_only_assistant_sends_empty_content(): vision=False, ) assert out[0]["role"] == "assistant" - assert out[0]["content"] == "" + assert out[0]["content"] == _EMPTY_ASSISTANT_CONTENT + assert out[0]["content"] 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 + + def test_tool_call_assistant_may_have_null_content(): out = messages_to_openai( [Message("assistant", [ @@ -165,6 +175,66 @@ def test_forced_tool_choice_disables_thinking(): assert captured["tool_choice"]["function"]["name"] == "save_resolved_specs" +def test_thinking_only_retries_with_thinking_disabled(): + calls: list[dict] = [] + + class FakeCompletions: + async def create(self, **kwargs): + calls.append({ + "thinking": kwargs["extra_body"]["thinking"]["type"], + }) + usage = SimpleNamespace( + prompt_tokens=10, completion_tokens=5, + prompt_cache_hit_tokens=0, prompt_tokens_details=None, + ) + if kwargs["extra_body"]["thinking"]["type"] == "enabled": + msg = SimpleNamespace( + content=None, + reasoning_content="pondering the schematic", + tool_calls=None, + ) + return SimpleNamespace( + choices=[SimpleNamespace(message=msg, finish_reason="stop")], + usage=usage, + ) + fn = SimpleNamespace(name="get_pintable", arguments='{"ref":"U19"}') + tc = SimpleNamespace(id="c1", function=fn) + msg = SimpleNamespace(content=None, reasoning_content=None, tool_calls=[tc]) + return SimpleNamespace( + choices=[SimpleNamespace(message=msg, finish_reason="tool_calls")], + usage=usage, + ) + + class FakeClient: + def __init__(self): + self.chat = SimpleNamespace(completions=FakeCompletions()) + + from backend.services.llm.deepseek_provider import DeepSeekSession + from backend.services.llm.types import Message, TextBlock, ToolSchema + import asyncio + + session = DeepSeekSession( + client=FakeClient(), + model="deepseek-v4-pro", + system="sys", + max_tokens=256, + thinking=True, + reasoning_effort="medium", + ) + completion = asyncio.run(session.complete( + messages=[Message("user", [TextBlock("review U19")])], + tools=[ToolSchema( + name="get_pintable", description="x", + input_schema={"type": "object"}, + )], + tool_choice="auto", + )) + assert len(calls) == 2 + assert calls[0]["thinking"] == "enabled" + assert calls[1]["thinking"] == "disabled" + assert completion.tool_calls[0].name == "get_pintable" + + def test_tool_schema_and_choice(): schema = ToolSchema( name="save_pintable",