fix(deepseek): use native /anthropic/v1/messages endpoint and sanitize tools (#28200)

* 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.

* 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.

* chore(deepseek): format messages transformation

* chore(deepseek): add test package markers

* fix(deepseek): tighten anthropic url path check and fall back to DEEPSEEK_API_BASE

Author: mateo-berri <277851410+mateo-berri@users.noreply.github.com>

* fix(tests): normalize smart quotes in realtime guardrail refusal check

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.

* fix(deepseek): drop redundant anthropic v1/messages endswith check

* fix(deepseek): strip /beta suffix in anthropic messages URL normalization

Co-authored-by: Yassin Kortam <yassin@berri.ai>

---------

Co-authored-by: Felipe Rodrigues Gare Carnielli <felipe.gare@hotmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
This commit is contained in:
Mateo Wang 2026-05-18 18:14:13 -07:00 committed by GitHub
parent 761ab19209
commit 761c280a6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 336 additions and 1 deletions

View File

@ -0,0 +1,133 @@
"""
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 get_secret_str("DEEPSEEK_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") 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"):
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"
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

View File

@ -8539,6 +8539,12 @@ class ProviderConfigManager:
)
return MinimaxMessagesConfig()
elif litellm.LlmProviders.DEEPSEEK == provider:
from litellm.llms.deepseek.messages.transformation import (
DeepSeekAnthropicMessagesConfig,
)
return DeepSeekAnthropicMessagesConfig()
return None
@staticmethod

View File

@ -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:

View File

@ -0,0 +1,189 @@
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"
)
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():
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"