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:
parent
761ab19209
commit
761c280a6e
133
litellm/llms/deepseek/messages/transformation.py
Normal file
133
litellm/llms/deepseek/messages/transformation.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
0
tests/test_litellm/llms/deepseek/__init__.py
Normal file
0
tests/test_litellm/llms/deepseek/__init__.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user