diff --git a/litellm/llms/deepseek/messages/transformation.py b/litellm/llms/deepseek/messages/transformation.py index 3b2076b0085..b0eacb2204e 100644 --- a/litellm/llms/deepseek/messages/transformation.py +++ b/litellm/llms/deepseek/messages/transformation.py @@ -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 diff --git a/tests/unit/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py b/tests/unit/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py index 316b8576ac4..9e8af157f55 100644 --- a/tests/unit/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py +++ b/tests/unit/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py @@ -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