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:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user