This commit is contained in:
Gavin Zeng 2026-11-07 13:34:26 +08:00 • committed by GitHub
commit 6ff47439ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 204 additions and 2 deletions

View file

@ -8,6 +8,7 @@ from typing import Final
from pydantic import TypeAdapter
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.anthropic.messages.native.transformation import (
AnthropicMessagesConfig,
@ -122,6 +123,65 @@ class DeepSeekAnthropicMessagesConfig(AnthropicMessagesConfig):
return [_sanitized_tool(tool) for tool in _TOOL_LIST.validate_python(tools)]
def _fill_reasoning_content(self, messages: list[dict]) -> list[dict]: # mutable-ok: anthropic wire payload
"""
DeepSeek thinking mode requires `reasoning_content` to be passed back on
every assistant message in multi-turn conversations. If it is missing,
the anthropic-compatible API returns:
"The reasoning_content in the thinking mode must be passed back to the API."
This mirrors `DeepSeekChatConfig._fill_reasoning_content`
(litellm/llms/deepseek/chat/transformation.py) for the anthropic-compat
endpoint — both endpoints enforce the same requirement, but only the
OpenAI-compat path filled the field.
For each assistant message that is missing `reasoning_content`:
1. Promote it from `provider_specific_fields["reasoning_content"]` if
present (LiteLLM stores provider-specific response fields there).
2. Otherwise inject a single space — the minimum value the API accepts.
"""
result: Final[list[dict]] = [] # mutable-ok: append-built mirror of chat path
for msg in messages:
if isinstance(msg, dict) and msg.get("role") == "assistant" and not msg.get("reasoning_content"):
patched = dict(msg)
provider_fields = patched.get("provider_specific_fields") or {}
stored = provider_fields.get("reasoning_content")
if stored:
patched["reasoning_content"] = stored
cleaned = dict(provider_fields)
cleaned.pop("reasoning_content", None)
patched["provider_specific_fields"] = cleaned
else:
verbose_logger.warning(
"DeepSeek thinking mode: assistant message is missing "
"`reasoning_content` and none was saved in "
"`provider_specific_fields`. A single-space placeholder "
"is being injected to satisfy API validation, but the "
"model will receive a blank reasoning chain for this turn, "
"which may silently degrade multi-turn response quality. "
"Preserve `reasoning_content` from the original assistant "
"response when building multi-turn conversation history."
)
patched["reasoning_content"] = " "
result.append(patched)
else:
result.append(msg)
return result
def _thinking_mode_active(self, model: str, optional_params: dict) -> bool: # mutable-ok: read-only caller params
"""
Returns True only when the request explicitly carries
`thinking: {"type": "enabled"}` — the same opt-in signal the
upstream DeepSeek anthropic-compat endpoint enforces.
Unlike the chat-side `DeepSeekChatConfig._thinking_mode_active`,
we do NOT gate on `supports_reasoning(model, custom_llm_provider)`.
The anthropic-compat endpoint accepts the native anthropic message
shape (where `thinking` is always opt-in), so any request that
actually turns thinking on is one we must backfill for.
"""
return (optional_params.get("thinking") or {}).get("type") == "enabled"
def transform_anthropic_messages_request(
self,
model: str,
@ -139,4 +199,11 @@ class DeepSeekAnthropicMessagesConfig(AnthropicMessagesConfig):
)
if "tools" in anthropic_messages_request:
anthropic_messages_request["tools"] = self._sanitize_tools_for_deepseek(anthropic_messages_request["tools"])
if (
self._thinking_mode_active(model=model, optional_params=anthropic_messages_optional_request_params)
and "messages" in anthropic_messages_request
):
anthropic_messages_request["messages"] = self._fill_reasoning_content(
anthropic_messages_request["messages"]
)
return anthropic_messages_request

View file

@ -120,7 +120,15 @@ def test_deepseek_anthropic_messages_headers_use_deepseek_key():
assert headers["content-type"] == "application/json"
def test_deepseek_anthropic_messages_preserves_thinking_and_sanitizes_custom_tools():
def test_deepseek_anthropic_messages_preserves_thinking_sanitizes_tools_and_backfills_reasoning_content():
"""
Thinking-mode request through the DeepSeek anthropic endpoint:
- thinking blocks in assistant history are preserved (untouched)
- custom tool discriminator is stripped (existing behaviour)
- assistant messages missing `reasoning_content` get a placeholder
backfill so DeepSeek does not reject the request with
"The reasoning_content in the thinking mode must be passed back"
"""
config = DeepSeekAnthropicMessagesConfig()
messages = [
{
@ -179,11 +187,138 @@ def test_deepseek_anthropic_messages_preserves_thinking_and_sanitizes_custom_too
headers={},
)
assert request["messages"] == messages
# thinking param preserved
assert request["thinking"] == {"type": "enabled", "budget_tokens": 1024}
# tools sanitized (custom stripped, hosted tool kept)
assert request["tools"][0] == {
"name": "get_weather",
"description": "Get weather",
"input_schema": {"type": "object"},
}
assert request["tools"][1]["type"] == "web_search_20260209"
# user + tool_result messages unchanged
assert request["messages"][0] == messages[0]
assert request["messages"][2] == messages[2]
# assistant message: thinking block content preserved, reasoning_content backfilled
assistant = request["messages"][1]
assert assistant["content"] == messages[1]["content"]
assert assistant["reasoning_content"] == " "
# ---------------------------------------------------------------------------
# reasoning_content backfill unit tests
# ---------------------------------------------------------------------------
def test_fill_reasoning_content_injects_placeholder_when_missing():
config = DeepSeekAnthropicMessagesConfig()
out = config._fill_reasoning_content(
[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
)
assert out[1]["reasoning_content"] == " "
assert "reasoning_content" not in out[0]
def test_fill_reasoning_content_preserves_existing_value():
config = DeepSeekAnthropicMessagesConfig()
out = config._fill_reasoning_content(
[
{
"role": "assistant",
"content": "x",
"reasoning_content": "real chain",
}
]
)
assert out[0]["reasoning_content"] == "real chain"
def test_fill_reasoning_content_promotes_from_provider_specific_fields():
config = DeepSeekAnthropicMessagesConfig()
out = config._fill_reasoning_content(
[
{
"role": "assistant",
"content": "x",
"provider_specific_fields": {
"reasoning_content": "stored chain",
"other": "keep",
},
}
]
)
assert out[0]["reasoning_content"] == "stored chain"
assert "reasoning_content" not in out[0]["provider_specific_fields"]
assert out[0]["provider_specific_fields"]["other"] == "keep"
def test_fill_reasoning_content_skips_non_assistant_messages():
config = DeepSeekAnthropicMessagesConfig()
out = config._fill_reasoning_content(
[
{"role": "user", "content": "hi"},
{"role": "system", "content": "sys"},
]
)
assert "reasoning_content" not in out[0]
assert "reasoning_content" not in out[1]
def test_fill_reasoning_content_handles_empty_list():
config = DeepSeekAnthropicMessagesConfig()
assert config._fill_reasoning_content([]) == []
def test_fill_reasoning_content_does_not_mutate_input():
config = DeepSeekAnthropicMessagesConfig()
src = [{"role": "assistant", "content": "x"}]
src_snapshot = dict(src[0])
config._fill_reasoning_content(src)
assert src[0] == src_snapshot
def test_transform_does_not_backfill_when_thinking_disabled():
"""Non-thinking requests must not get spurious reasoning_content injection."""
config = DeepSeekAnthropicMessagesConfig()
out = config.transform_anthropic_messages_request(
model="deepseek-v4-pro",
messages=[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": [{"type": "text", "text": "hello"}]},
],
anthropic_messages_optional_request_params={"max_tokens": 100},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert "reasoning_content" not in out["messages"][1]
def test_thinking_mode_active_returns_true_only_when_explicitly_enabled():
"""Guard must fire ONLY on `thinking: {type: enabled}`, not other shapes."""
config = DeepSeekAnthropicMessagesConfig()
# explicit enabled -> fill
assert config._thinking_mode_active(
model="deepseek-v4-pro",
optional_params={"thinking": {"type": "enabled", "budget_tokens": 1024}},
) is True
# type=disabled -> no fill
assert config._thinking_mode_active(
model="deepseek-v4-pro",
optional_params={"thinking": {"type": "disabled"}},
) is False
# thinking absent -> no fill (most common non-thinking case)
assert config._thinking_mode_active(
model="deepseek-v4-pro",
optional_params={"max_tokens": 100},
) is False
# thinking present but not a dict (defensive) -> no fill
assert config._thinking_mode_active(
model="deepseek-v4-pro",
optional_params={"thinking": None},
) is False