From 0da6e8da8d2a52a5ac3527c4a2e18361dc340224 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 6 Oct 2026 23:23:24 +0800 Subject: [PATCH] lmproto: give every DeepSeek assistant round its reasoning field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thinking mode checks the field per round, not per call: an answer whose message carried no `reasoning_content` came back 400 with the wording a call gets ("the `reasoning_content` … must be passed back to the API"), while an empty string satisfies the check. The injection now touches every assistant message instead of only the ones carrying `tool_calls`. --- .../lmproto/deepseek/openai_compat/client.py | 29 +++++- tests/test_lmproto/test_deepseek_chat.py | 88 ++++++++++++++++++- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/plyngent/lmproto/deepseek/openai_compat/client.py b/src/plyngent/lmproto/deepseek/openai_compat/client.py index 01ba74d..57c89bb 100644 --- a/src/plyngent/lmproto/deepseek/openai_compat/client.py +++ b/src/plyngent/lmproto/deepseek/openai_compat/client.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING, Literal, overload +from typing import TYPE_CHECKING, Literal, cast, overload import msgspec @@ -30,6 +30,32 @@ def _inject_thinking(data: bytes, param: ChatCompletionsParam) -> bytes: return json.dumps(body, separators=(",", ":")).encode("utf-8") +def _inject_missing_reasoning_content(data: bytes) -> bytes: + """Give every assistant message a ``reasoning_content`` field. + + Thinking mode rejects an assistant round whose message carries no reasoning + ("the ``reasoning_content`` … must be passed back to the API") — for a tool + call and for a plain answer alike. History can hold such a round: a + rolled-back turn keeps the calls that ran as a bare pairing carrier without + it (see ``rollback_tail``), a synthetic ``todo_list`` nag is forged the same + way, and a compacted session starts with a seed summary carrying none. The + field only has to be there, so an empty string satisfies the check (unlike + the Responses API, whose ``reasoning`` item needs non-empty text — see + ``agent.responses_bridge``). + """ + body = cast("dict[str, object]", json.loads(data)) + messages = body.get("messages") + if isinstance(messages, list): + for message in cast("list[object]", messages): + if not isinstance(message, dict): + continue + item = cast("dict[str, object]", message) + if item.get("role") != "assistant" or "reasoning_content" in item: + continue + item["reasoning_content"] = "" + return json.dumps(body, separators=(",", ":")).encode("utf-8") + + class DeepseekOpenAIClient(BaseOpenAIClient): kind: str = "chat_completions" @@ -51,6 +77,7 @@ class DeepseekOpenAIClient(BaseOpenAIClient): ) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: param = coerce_chat_completions_param_any(msgspec.structs.replace(param, stream=stream)) data = _inject_thinking(self.encoder.encode(param), param) + data = _inject_missing_reasoning_content(data) if stream: resp = await self.session.post( "/chat/completions", diff --git a/tests/test_lmproto/test_deepseek_chat.py b/tests/test_lmproto/test_deepseek_chat.py index 146b176..e447cab 100644 --- a/tests/test_lmproto/test_deepseek_chat.py +++ b/tests/test_lmproto/test_deepseek_chat.py @@ -1,27 +1,41 @@ -"""Tests for the DeepSeek chat-completions client's ``thinking`` flag.""" +"""Tests for the DeepSeek chat-completions client's wire quirks. + +``thinking`` (an explicit flag the API wants) and the ``reasoning_content`` +field a tool-call message must carry back in thinking mode. +""" from __future__ import annotations import json from typing import TYPE_CHECKING, Any, cast +import msgspec import pytest from msgspec import UNSET from plyngent.lmproto.deepseek import DeepseekOpenAIClient from plyngent.lmproto.openai_compatible.config import OpenAIConfig -from plyngent.lmproto.openai_compatible.model import ChatCompletionsParam, UserChatMessage +from plyngent.lmproto.openai_compatible.model import ( + AssistantChatMessage, + AssistantFunctionTool, + AssistantFunctionToolCall, + ChatCompletionsParam, + ToolChatMessage, + UserChatMessage, +) if TYPE_CHECKING: from collections.abc import AsyncIterator - from plyngent.lmproto.openai_compatible.model import ReasoningEffort + from plyngent.lmproto.openai_compatible.model import AnyChatMessage, ReasoningEffort from plyngent.typedef import Unset async def _posted_body( monkeypatch: pytest.MonkeyPatch, effort: ReasoningEffort | Unset = UNSET, + *, + messages: list[AnyChatMessage] | None = None, ) -> dict[str, Any]: """Run one streamed request and return the JSON body the client posted.""" client = DeepseekOpenAIClient(OpenAIConfig(access_key_or_token="sk-test", base_url="https://api.deepseek.com")) @@ -44,7 +58,7 @@ async def _posted_body( monkeypatch.setattr(client.session, "post", fake_post) param = ChatCompletionsParam( model="deepseek-v4-pro", - messages=[UserChatMessage(content="hi")], + messages=messages if messages is not None else [UserChatMessage(content="hi")], reasoning_effort=effort, ) # Production dispatches the base param through the OpenAI-compatible surface @@ -77,3 +91,69 @@ async def test_thinking_disabled_for_none_effort(monkeypatch: pytest.MonkeyPatch body = await _posted_body(monkeypatch, "none") assert body["thinking"] == {"type": "disabled"} assert body["reasoning_effort"] == "none" + + +def _kept_call() -> AssistantChatMessage: + """The pairing carrier ``rollback_tail`` keeps for a call that ran.""" + return AssistantChatMessage( + content=None, + tool_calls=[ + AssistantFunctionToolCall( + id="call_1", + function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'), + ) + ], + ) + + +@pytest.mark.asyncio +async def test_tool_call_without_recorded_reasoning_gets_an_empty_field( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The API refuses a call whose ``reasoning_content`` never came back. + + A rolled-back turn keeps its executed calls without one, and a synthetic + ``todo_list`` nag is forged the same way; the field only has to be present, + so an empty string satisfies the check. + """ + body = await _posted_body( + monkeypatch, + messages=[ + UserChatMessage(content="read it"), + _kept_call(), + ToolChatMessage(content="file body", tool_call_id="call_1"), + ], + ) + assistant = body["messages"][1] + assert assistant["reasoning_content"] == "" + assert assistant["tool_calls"][0]["id"] == "call_1" + + +@pytest.mark.asyncio +async def test_recorded_reasoning_content_is_left_alone(monkeypatch: pytest.MonkeyPatch) -> None: + call = _kept_call() + messages: list[AnyChatMessage] = [ + UserChatMessage(content="read it"), + msgspec.structs.replace(call, reasoning_content="Open the file first."), + ToolChatMessage(content="file body", tool_call_id="call_1"), + ] + body = await _posted_body(monkeypatch, messages=messages) + assert body["messages"][1]["reasoning_content"] == "Open the file first." + + +@pytest.mark.asyncio +async def test_answer_without_recorded_reasoning_gets_an_empty_field( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A round that called nothing needs the field too. + + The rule is per round, not per call: the API answers "the + ``reasoning_content`` … must be passed back" for an answer round that never + reasoned as well, and an empty string satisfies it. + """ + body = await _posted_body( + monkeypatch, + messages=[UserChatMessage(content="hi"), AssistantChatMessage(content="all done")], + ) + assert body["messages"][1]["reasoning_content"] == "" + assert body["messages"][1]["content"] == "all done"