mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge df27beac96 into 72228db5c6
This commit is contained in:
commit
6ff47439ed
2 changed files with 204 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue