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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 08:15:07 +02:00
co-authored by Cursor
parent e5e8c42966
commit a9238875dd
2 changed files with 137 additions and 10 deletions
+64 -7
View File
@@ -16,7 +16,7 @@ import json
import logging import logging
from typing import Any from typing import Any
from openai import AsyncOpenAI from openai import APIStatusError, AsyncOpenAI
from backend.config import settings from backend.config import settings
from backend.services.llm.base import LLMProvider, LLMSession from backend.services.llm.base import LLMProvider, LLMSession
@@ -38,6 +38,10 @@ 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:
@@ -97,6 +101,26 @@ def _user_content_parts(blocks: list[ContentBlock], *, vision: bool) -> list[dic
return parts 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]: def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]:
"""Convert unified messages into DeepSeek/OpenAI chat messages. """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)] 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 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: if tool_calls:
msg["content"] = text if text else None msg["content"] = text if text else None
msg["tool_calls"] = [ msg["tool_calls"] = [
@@ -128,7 +149,7 @@ def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]:
for tc in tool_calls for tc in tool_calls
] ]
else: else:
msg["content"] = text 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
@@ -281,8 +302,44 @@ class DeepSeekSession(LLMSession):
kwargs["tools"] = [_to_openai_tool(t) for t in tools] kwargs["tools"] = [_to_openai_tool(t) for t in tools]
kwargs["tool_choice"] = _to_openai_tool_choice(tool_choice) kwargs["tool_choice"] = _to_openai_tool_choice(tool_choice)
resp = await self._client.chat.completions.create(**kwargs) resp = await self._create(kwargs)
return completion_from_openai(resp) 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: async def close(self) -> None:
return None return None
+73 -3
View File
@@ -72,8 +72,10 @@ 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_empty_content(): def test_thinking_only_assistant_sends_placeholder_content():
"""Thinking with no visible text and no tools must still set 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( 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."),
@@ -81,11 +83,19 @@ def test_thinking_only_assistant_sends_empty_content():
vision=False, vision=False,
) )
assert out[0]["role"] == "assistant" 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 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():
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(): def test_tool_call_assistant_may_have_null_content():
out = messages_to_openai( out = messages_to_openai(
[Message("assistant", [ [Message("assistant", [
@@ -165,6 +175,66 @@ def test_forced_tool_choice_disables_thinking():
assert captured["tool_choice"]["function"]["name"] == "save_resolved_specs" 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(): def test_tool_schema_and_choice():
schema = ToolSchema( schema = ToolSchema(
name="save_pintable", name="save_pintable",