mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
lmproto: give every DeepSeek assistant round its reasoning field
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`.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING, Literal, overload
|
from typing import TYPE_CHECKING, Literal, cast, overload
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
@@ -30,6 +30,32 @@ def _inject_thinking(data: bytes, param: ChatCompletionsParam) -> bytes:
|
|||||||
return json.dumps(body, separators=(",", ":")).encode("utf-8")
|
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):
|
class DeepseekOpenAIClient(BaseOpenAIClient):
|
||||||
kind: str = "chat_completions"
|
kind: str = "chat_completions"
|
||||||
|
|
||||||
@@ -51,6 +77,7 @@ class DeepseekOpenAIClient(BaseOpenAIClient):
|
|||||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||||
param = coerce_chat_completions_param_any(msgspec.structs.replace(param, stream=stream))
|
param = coerce_chat_completions_param_any(msgspec.structs.replace(param, stream=stream))
|
||||||
data = _inject_thinking(self.encoder.encode(param), param)
|
data = _inject_thinking(self.encoder.encode(param), param)
|
||||||
|
data = _inject_missing_reasoning_content(data)
|
||||||
if stream:
|
if stream:
|
||||||
resp = await self.session.post(
|
resp = await self.session.post(
|
||||||
"/chat/completions",
|
"/chat/completions",
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
|
import msgspec
|
||||||
import pytest
|
import pytest
|
||||||
from msgspec import UNSET
|
from msgspec import UNSET
|
||||||
|
|
||||||
from plyngent.lmproto.deepseek import DeepseekOpenAIClient
|
from plyngent.lmproto.deepseek import DeepseekOpenAIClient
|
||||||
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
|
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:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
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
|
from plyngent.typedef import Unset
|
||||||
|
|
||||||
|
|
||||||
async def _posted_body(
|
async def _posted_body(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
effort: ReasoningEffort | Unset = UNSET,
|
effort: ReasoningEffort | Unset = UNSET,
|
||||||
|
*,
|
||||||
|
messages: list[AnyChatMessage] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run one streamed request and return the JSON body the client posted."""
|
"""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"))
|
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)
|
monkeypatch.setattr(client.session, "post", fake_post)
|
||||||
param = ChatCompletionsParam(
|
param = ChatCompletionsParam(
|
||||||
model="deepseek-v4-pro",
|
model="deepseek-v4-pro",
|
||||||
messages=[UserChatMessage(content="hi")],
|
messages=messages if messages is not None else [UserChatMessage(content="hi")],
|
||||||
reasoning_effort=effort,
|
reasoning_effort=effort,
|
||||||
)
|
)
|
||||||
# Production dispatches the base param through the OpenAI-compatible surface
|
# 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")
|
body = await _posted_body(monkeypatch, "none")
|
||||||
assert body["thinking"] == {"type": "disabled"}
|
assert body["thinking"] == {"type": "disabled"}
|
||||||
assert body["reasoning_effort"] == "none"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user