From cf03cb9acfa83cb0ed7e776555fd22b65d2feec1 Mon Sep 17 00:00:00 2001 From: Felipe Rodrigues Gare Carnielli Date: Mon, 18 May 2026 16:05:53 -0300 Subject: [PATCH 1/8] fix(deepseek): route messages api through anthropic config Add a DeepSeek-specific Anthropic Messages config so deepseek/... models use the native messages endpoint and preserve thinking blocks. Strip Anthropic custom tool type markers that DeepSeek rejects while keeping hosted tool types intact. --- .../llms/deepseek/messages/transformation.py | 118 ++++++++++++ litellm/utils.py | 6 + ...pseek_anthropic_messages_transformation.py | 169 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 litellm/llms/deepseek/messages/transformation.py create mode 100644 tests/test_litellm/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py diff --git a/litellm/llms/deepseek/messages/transformation.py b/litellm/llms/deepseek/messages/transformation.py new file mode 100644 index 000000000000..a0e8db077ae0 --- /dev/null +++ b/litellm/llms/deepseek/messages/transformation.py @@ -0,0 +1,118 @@ +""" +DeepSeek Anthropic-compatible messages transformation config. +""" + +from typing import Any, Dict, List, Optional, Tuple + +import litellm +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) +from litellm.secret_managers.main import get_secret_str +from litellm.types.router import GenericLiteLLMParams + + +class DeepSeekAnthropicMessagesConfig(AnthropicMessagesConfig): + """ + DeepSeek exposes an Anthropic-compatible Messages API at + https://api.deepseek.com/anthropic. + + It accepts the native Anthropic Messages conversation shape, including + thinking blocks in assistant history, but rejects Anthropic's explicit + custom-tool discriminator (`{"type": "custom"}`). + """ + + @property + def custom_llm_provider(self) -> Optional[str]: + return "deepseek" + + @staticmethod + def get_api_key(api_key: Optional[str] = None) -> Optional[str]: + return api_key or get_secret_str("DEEPSEEK_API_KEY") or litellm.api_key + + @staticmethod + def get_api_base(api_base: Optional[str] = None) -> str: + return api_base or get_secret_str("DEEPSEEK_ANTHROPIC_API_BASE") or "https://api.deepseek.com/anthropic" + + def validate_anthropic_messages_environment( + self, + headers: dict, + model: str, + messages: List[Any], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> Tuple[dict, Optional[str]]: + dynamic_api_key = self.get_api_key(api_key=api_key) + + if "x-api-key" not in headers and "authorization" not in headers and dynamic_api_key is not None: + headers["x-api-key"] = dynamic_api_key + + if "anthropic-version" not in headers: + headers["anthropic-version"] = "2023-06-01" + if "content-type" not in headers: + headers["content-type"] = "application/json" + + headers = self._update_headers_with_anthropic_beta( + headers=headers, + optional_params=optional_params, + custom_llm_provider=self.custom_llm_provider or "deepseek", + ) + + return headers, api_base + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + base_url = self.get_api_base(api_base=api_base).rstrip("/") + + if base_url.endswith("/v1/messages"): + return base_url + if base_url.endswith("/v1"): + return f"{base_url}/messages" + + if "/anthropic" not in base_url: + base_url = f"{base_url}/anthropic" + + return f"{base_url}/v1/messages" + + @staticmethod + def _sanitize_tools_for_deepseek(tools: Any) -> Any: + if not isinstance(tools, list): + return tools + + sanitized_tools = [] + for tool in tools: + if isinstance(tool, dict) and tool.get("type") == "custom": + sanitized_tool = dict(tool) + sanitized_tool.pop("type", None) + sanitized_tools.append(sanitized_tool) + else: + sanitized_tools.append(tool) + return sanitized_tools + + def transform_anthropic_messages_request( + self, + model: str, + messages: List[Dict], + anthropic_messages_optional_request_params: Dict, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Dict: + anthropic_messages_request = super().transform_anthropic_messages_request( + model=model, + messages=messages, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + litellm_params=litellm_params, + headers=headers, + ) + if "tools" in anthropic_messages_request: + anthropic_messages_request["tools"] = self._sanitize_tools_for_deepseek(anthropic_messages_request["tools"]) + return anthropic_messages_request diff --git a/litellm/utils.py b/litellm/utils.py index 54cea313b0c4..001c89fee4c6 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8539,6 +8539,12 @@ def _get_provider_anthropic_messages_config_cached( ) return MinimaxMessagesConfig() + elif litellm.LlmProviders.DEEPSEEK == provider: + from litellm.llms.deepseek.messages.transformation import ( + DeepSeekAnthropicMessagesConfig, + ) + + return DeepSeekAnthropicMessagesConfig() return None @staticmethod diff --git a/tests/test_litellm/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py b/tests/test_litellm/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py new file mode 100644 index 000000000000..52e2e1c35453 --- /dev/null +++ b/tests/test_litellm/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py @@ -0,0 +1,169 @@ +import litellm +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) +from litellm.llms.deepseek.messages.transformation import ( + DeepSeekAnthropicMessagesConfig, +) +from litellm.types.router import GenericLiteLLMParams +from litellm.utils import ProviderConfigManager + + +def test_deepseek_provider_uses_anthropic_messages_config(): + config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="deepseek-v4-pro", + provider=litellm.LlmProviders.DEEPSEEK, + ) + + assert isinstance(config, DeepSeekAnthropicMessagesConfig) + assert config.custom_llm_provider == "deepseek" + + +def test_deepseek_anthropic_messages_config_defaults(): + config = DeepSeekAnthropicMessagesConfig() + + assert config.custom_llm_provider == "deepseek" + assert config.get_api_base() == "https://api.deepseek.com/anthropic" + + +def test_anthropic_provider_keeps_default_config_for_deepseek_named_model(): + config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="deepseek-v4-pro", + provider=litellm.LlmProviders.ANTHROPIC, + ) + + assert isinstance(config, AnthropicMessagesConfig) + assert not isinstance(config, DeepSeekAnthropicMessagesConfig) + + +def test_deepseek_anthropic_messages_url_defaults_to_anthropic_endpoint(): + config = DeepSeekAnthropicMessagesConfig() + + assert ( + config.get_complete_url( + api_base=None, + api_key=None, + model="deepseek-v4-pro", + optional_params={}, + litellm_params={}, + ) + == "https://api.deepseek.com/anthropic/v1/messages" + ) + assert ( + config.get_complete_url( + api_base="https://api.deepseek.com/anthropic/v1", + api_key=None, + model="deepseek-v4-pro", + optional_params={}, + litellm_params={}, + ) + == "https://api.deepseek.com/anthropic/v1/messages" + ) + assert ( + config.get_complete_url( + api_base="https://api.deepseek.com/anthropic", + api_key=None, + model="deepseek-v4-pro", + optional_params={}, + litellm_params={}, + ) + == "https://api.deepseek.com/anthropic/v1/messages" + ) + assert ( + config.get_complete_url( + api_base="https://api.deepseek.com", + api_key=None, + model="deepseek-v4-pro", + optional_params={}, + litellm_params={}, + ) + == "https://api.deepseek.com/anthropic/v1/messages" + ) + + +def test_deepseek_anthropic_messages_headers_use_deepseek_key(): + config = DeepSeekAnthropicMessagesConfig() + + headers, api_base = config.validate_anthropic_messages_environment( + headers={}, + model="deepseek-v4-pro", + messages=[], + optional_params={}, + litellm_params={}, + api_key="sk-deepseek", + api_base="https://example.test/anthropic", + ) + + assert api_base == "https://example.test/anthropic" + assert headers["x-api-key"] == "sk-deepseek" + assert headers["anthropic-version"] == "2023-06-01" + assert headers["content-type"] == "application/json" + + +def test_deepseek_anthropic_messages_preserves_thinking_and_sanitizes_custom_tools(): + config = DeepSeekAnthropicMessagesConfig() + messages = [ + { + "role": "user", + "content": "Use the tool.", + }, + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "I should call the tool.", + "signature": "sig", + }, + { + "type": "tool_use", + "id": "toolu_123", + "name": "get_weather", + "input": {"city": "Sao Paulo"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_123", + "content": "Sunny", + } + ], + }, + ] + + request = config.transform_anthropic_messages_request( + model="deepseek-v4-pro", + messages=messages, + anthropic_messages_optional_request_params={ + "max_tokens": 100, + "thinking": {"type": "enabled", "budget_tokens": 1024}, + "tools": [ + { + "type": "custom", + "name": "get_weather", + "description": "Get weather", + "input_schema": {"type": "object"}, + }, + { + "type": "web_search_20260209", + "name": "web_search", + "max_uses": 1, + }, + ], + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert request["messages"] == messages + assert request["thinking"] == {"type": "enabled", "budget_tokens": 1024} + assert request["tools"][0] == { + "name": "get_weather", + "description": "Get weather", + "input_schema": {"type": "object"}, + } + assert request["tools"][1]["type"] == "web_search_20260209" From 9cdf7561cddb6982a2d220ff6335f4899d5b6ffa Mon Sep 17 00:00:00 2001 From: Felipe Rodrigues Gare Carnielli Date: Mon, 18 May 2026 16:48:55 -0300 Subject: [PATCH 2/8] fix(deepseek): normalize anthropic messages api base Handle OpenAI-style DeepSeek api_base values ending in /v1 or /v1/messages by stripping those suffixes before adding the /anthropic messages path. --- .../llms/deepseek/messages/transformation.py | 6 ++++-- ...pseek_anthropic_messages_transformation.py | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/litellm/llms/deepseek/messages/transformation.py b/litellm/llms/deepseek/messages/transformation.py index a0e8db077ae0..8ce3ab1b68e5 100644 --- a/litellm/llms/deepseek/messages/transformation.py +++ b/litellm/llms/deepseek/messages/transformation.py @@ -73,10 +73,12 @@ def get_complete_url( ) -> str: base_url = self.get_api_base(api_base=api_base).rstrip("/") - if base_url.endswith("/v1/messages"): + if base_url.endswith("/v1/messages") and "/anthropic" in base_url: return base_url + if base_url.endswith("/v1/messages"): + base_url = base_url[: -len("/v1/messages")] if base_url.endswith("/v1"): - return f"{base_url}/messages" + base_url = base_url[: -len("/v1")] if "/anthropic" not in base_url: base_url = f"{base_url}/anthropic" diff --git a/tests/test_litellm/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py b/tests/test_litellm/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py index 52e2e1c35453..7c5f0483ded7 100644 --- a/tests/test_litellm/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py +++ b/tests/test_litellm/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py @@ -79,6 +79,26 @@ def test_deepseek_anthropic_messages_url_defaults_to_anthropic_endpoint(): ) == "https://api.deepseek.com/anthropic/v1/messages" ) + assert ( + config.get_complete_url( + api_base="https://api.deepseek.com/v1", + api_key=None, + model="deepseek-v4-pro", + optional_params={}, + litellm_params={}, + ) + == "https://api.deepseek.com/anthropic/v1/messages" + ) + assert ( + config.get_complete_url( + api_base="https://api.deepseek.com/v1/messages", + api_key=None, + model="deepseek-v4-pro", + optional_params={}, + litellm_params={}, + ) + == "https://api.deepseek.com/anthropic/v1/messages" + ) def test_deepseek_anthropic_messages_headers_use_deepseek_key(): From 2e8967b25f6f92c53d313af216f41cef77084706 Mon Sep 17 00:00:00 2001 From: Felipe Rodrigues Gare Carnielli Date: Mon, 18 May 2026 17:07:34 -0300 Subject: [PATCH 3/8] chore(deepseek): format messages transformation --- litellm/llms/deepseek/messages/transformation.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/litellm/llms/deepseek/messages/transformation.py b/litellm/llms/deepseek/messages/transformation.py index 8ce3ab1b68e5..0bfc59cc2466 100644 --- a/litellm/llms/deepseek/messages/transformation.py +++ b/litellm/llms/deepseek/messages/transformation.py @@ -32,7 +32,11 @@ def get_api_key(api_key: Optional[str] = None) -> Optional[str]: @staticmethod def get_api_base(api_base: Optional[str] = None) -> str: - return api_base or get_secret_str("DEEPSEEK_ANTHROPIC_API_BASE") or "https://api.deepseek.com/anthropic" + return ( + api_base + or get_secret_str("DEEPSEEK_ANTHROPIC_API_BASE") + or "https://api.deepseek.com/anthropic" + ) def validate_anthropic_messages_environment( self, @@ -46,7 +50,11 @@ def validate_anthropic_messages_environment( ) -> Tuple[dict, Optional[str]]: dynamic_api_key = self.get_api_key(api_key=api_key) - if "x-api-key" not in headers and "authorization" not in headers and dynamic_api_key is not None: + if ( + "x-api-key" not in headers + and "authorization" not in headers + and dynamic_api_key is not None + ): headers["x-api-key"] = dynamic_api_key if "anthropic-version" not in headers: @@ -116,5 +124,7 @@ def transform_anthropic_messages_request( headers=headers, ) if "tools" in anthropic_messages_request: - anthropic_messages_request["tools"] = self._sanitize_tools_for_deepseek(anthropic_messages_request["tools"]) + anthropic_messages_request["tools"] = self._sanitize_tools_for_deepseek( + anthropic_messages_request["tools"] + ) return anthropic_messages_request From 1209a70dd19e0d798759a2ffb5b28bb8ca4bc7d6 Mon Sep 17 00:00:00 2001 From: Felipe Rodrigues Gare Carnielli Date: Mon, 18 May 2026 17:48:55 -0300 Subject: [PATCH 4/8] chore(deepseek): add test package markers --- tests/test_litellm/llms/deepseek/__init__.py | 0 tests/test_litellm/llms/deepseek/messages/__init__.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/test_litellm/llms/deepseek/__init__.py create mode 100644 tests/test_litellm/llms/deepseek/messages/__init__.py diff --git a/tests/test_litellm/llms/deepseek/__init__.py b/tests/test_litellm/llms/deepseek/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/test_litellm/llms/deepseek/messages/__init__.py b/tests/test_litellm/llms/deepseek/messages/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 From ea54d0536b5aaf9e0400c5dccc6e96322d8dec6b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 18 May 2026 23:56:03 +0000 Subject: [PATCH 5/8] fix(deepseek): tighten anthropic url path check and fall back to DEEPSEEK_API_BASE Author: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/llms/deepseek/messages/transformation.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/litellm/llms/deepseek/messages/transformation.py b/litellm/llms/deepseek/messages/transformation.py index 0bfc59cc2466..6e644d9fb462 100644 --- a/litellm/llms/deepseek/messages/transformation.py +++ b/litellm/llms/deepseek/messages/transformation.py @@ -35,6 +35,7 @@ def get_api_base(api_base: Optional[str] = None) -> str: return ( api_base or get_secret_str("DEEPSEEK_ANTHROPIC_API_BASE") + or get_secret_str("DEEPSEEK_API_BASE") or "https://api.deepseek.com/anthropic" ) @@ -81,14 +82,16 @@ def get_complete_url( ) -> str: base_url = self.get_api_base(api_base=api_base).rstrip("/") - if base_url.endswith("/v1/messages") and "/anthropic" in base_url: + if base_url.endswith("/v1/messages") and ( + "/anthropic/" in base_url or base_url.endswith("/anthropic/v1/messages") + ): return base_url if base_url.endswith("/v1/messages"): base_url = base_url[: -len("/v1/messages")] if base_url.endswith("/v1"): base_url = base_url[: -len("/v1")] - if "/anthropic" not in base_url: + if not base_url.endswith("/anthropic") and "/anthropic/" not in base_url: base_url = f"{base_url}/anthropic" return f"{base_url}/v1/messages" From 5fa3ed12906d5ce244275c54e328fd8c99c5cf8b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 19 May 2026 00:02:23 +0000 Subject: [PATCH 6/8] fix(tests): normalize smart quotes in realtime guardrail refusal check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpt-realtime nondeterministically returns refusals with Unicode curly apostrophes (e.g. 'I’m sorry, but I can’t assist with that.'), but the safe_markers tuple in test_text_message_blocked_by_guardrail_no_ai_response only contains straight ASCII apostrophes. The substring match then fails even though the response is a clear refusal, flipping CI red. Normalize the AI text to ASCII quotes before the marker check so both straight and curly variants count as safe outcomes. --- .../realtime/test_realtime_guardrails_openai.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/llm_translation/realtime/test_realtime_guardrails_openai.py b/tests/llm_translation/realtime/test_realtime_guardrails_openai.py index ec9d73e2d604..50cedba2ac08 100644 --- a/tests/llm_translation/realtime/test_realtime_guardrails_openai.py +++ b/tests/llm_translation/realtime/test_realtime_guardrails_openai.py @@ -232,8 +232,15 @@ async def test_text_message_blocked_by_guardrail_no_ai_response(): assert ( BLOCKED_PHRASE not in real_ai_text ), f"Blocked phrase leaked into AI response: {real_ai_text!r}" + normalized_ai_text = ( + real_ai_text.lower() + .replace("\u2019", "'") + .replace("\u2018", "'") + .replace("\u201c", '"') + .replace("\u201d", '"') + ) assert any( - marker in real_ai_text.lower() for marker in safe_markers + marker in normalized_ai_text for marker in safe_markers ), f"AI responded with non-guardrail content even though message was blocked: {real_ai_text!r}" finally: From 2b6c9bf6048089a62b2e8f6d179891c065d0cd3b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 19 May 2026 00:09:54 +0000 Subject: [PATCH 7/8] fix(deepseek): drop redundant anthropic v1/messages endswith check --- litellm/llms/deepseek/messages/transformation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/llms/deepseek/messages/transformation.py b/litellm/llms/deepseek/messages/transformation.py index 6e644d9fb462..3db77937dc0e 100644 --- a/litellm/llms/deepseek/messages/transformation.py +++ b/litellm/llms/deepseek/messages/transformation.py @@ -82,9 +82,7 @@ def get_complete_url( ) -> str: base_url = self.get_api_base(api_base=api_base).rstrip("/") - if base_url.endswith("/v1/messages") and ( - "/anthropic/" in base_url or base_url.endswith("/anthropic/v1/messages") - ): + if base_url.endswith("/v1/messages") and "/anthropic/" in base_url: return base_url if base_url.endswith("/v1/messages"): base_url = base_url[: -len("/v1/messages")] From 7cf68906ba586c24a7bb322f1af4130bbaeb64ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 19 May 2026 00:21:43 +0000 Subject: [PATCH 8/8] fix(deepseek): strip /beta suffix in anthropic messages URL normalization Co-authored-by: Yassin Kortam --- litellm/llms/deepseek/messages/transformation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/llms/deepseek/messages/transformation.py b/litellm/llms/deepseek/messages/transformation.py index 3db77937dc0e..ad60478960e7 100644 --- a/litellm/llms/deepseek/messages/transformation.py +++ b/litellm/llms/deepseek/messages/transformation.py @@ -88,6 +88,8 @@ def get_complete_url( base_url = base_url[: -len("/v1/messages")] if base_url.endswith("/v1"): base_url = base_url[: -len("/v1")] + if base_url.endswith("/beta"): + base_url = base_url[: -len("/beta")] if not base_url.endswith("/anthropic") and "/anthropic/" not in base_url: base_url = f"{base_url}/anthropic"