diff --git a/litellm/llms/dashscope/messages/__init__.py b/litellm/llms/dashscope/messages/__init__.py new file mode 100644 index 00000000000..84837de2b71 --- /dev/null +++ b/litellm/llms/dashscope/messages/__init__.py @@ -0,0 +1,3 @@ +from .transformation import DashScopeAnthropicMessagesConfig + +__all__ = ["DashScopeAnthropicMessagesConfig"] diff --git a/litellm/llms/dashscope/messages/transformation.py b/litellm/llms/dashscope/messages/transformation.py new file mode 100644 index 00000000000..afdc09af874 --- /dev/null +++ b/litellm/llms/dashscope/messages/transformation.py @@ -0,0 +1,99 @@ +""" +DashScope Anthropic transformation config - extends AnthropicMessagesConfig for +DashScope's Anthropic-compatible Messages API. +""" + +from typing import Any, List, Optional, Tuple + +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) +from litellm.secret_managers.main import get_secret_str + +DEFAULT_DASHSCOPE_ANTHROPIC_MESSAGES_API_BASE = "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages" +_COMPATIBLE_MODE_CHAT_COMPLETIONS_SUFFIX = "/compatible-mode/v1/chat/completions" +_COMPATIBLE_MODE_SUFFIX = "/compatible-mode/v1" +_ANTHROPIC_MESSAGES_SUFFIX = "/apps/anthropic" +_MESSAGES_SUFFIX = "/v1/messages" + + +class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): + """ + DashScope Anthropic configuration that extends AnthropicMessagesConfig. + + DashScope (Alibaba Cloud) exposes an Anthropic-compatible Messages API at: + - China: https://dashscope.aliyuncs.com/apps/anthropic/v1/messages + - International: https://dashscope-intl.aliyuncs.com/apps/anthropic/v1/messages + + The OpenAI-compatible base URLs (``.../compatible-mode/v1`` and + ``.../compatible-mode/v1/chat/completions``) are also recognized and + rewritten to the Anthropic Messages endpoint, since callers commonly reuse + the chat-completions base URL. + + Supported models: any DashScope-hosted model that is exposed via the + Anthropic Messages endpoint (e.g. ``qwen3-max``, ``qwen-plus``, + ``qwen-max``). + """ + + @property + def custom_llm_provider(self) -> Optional[str]: + return "dashscope" + + @staticmethod + def _get_api_key(api_key: Optional[str] = None) -> Optional[str]: + return api_key or get_secret_str("DASHSCOPE_API_KEY") + + @staticmethod + def _get_anthropic_messages_api_base(api_base: Optional[str] = None) -> str: + if not api_base: + return DEFAULT_DASHSCOPE_ANTHROPIC_MESSAGES_API_BASE + + base_url = api_base.rstrip("/") + if base_url.endswith(_MESSAGES_SUFFIX): + return base_url + if base_url.endswith(_COMPATIBLE_MODE_CHAT_COMPLETIONS_SUFFIX): + root = base_url[: -len(_COMPATIBLE_MODE_CHAT_COMPLETIONS_SUFFIX)] + return f"{root}{_ANTHROPIC_MESSAGES_SUFFIX}{_MESSAGES_SUFFIX}" + if base_url.endswith(_COMPATIBLE_MODE_SUFFIX): + root = base_url[: -len(_COMPATIBLE_MODE_SUFFIX)] + return f"{root}{_ANTHROPIC_MESSAGES_SUFFIX}{_MESSAGES_SUFFIX}" + if base_url.endswith(f"{_ANTHROPIC_MESSAGES_SUFFIX}/v1"): + return f"{base_url}/messages" + if base_url.endswith(_ANTHROPIC_MESSAGES_SUFFIX): + return f"{base_url}{_MESSAGES_SUFFIX}" + + return f"{base_url}{_MESSAGES_SUFFIX}" + + 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: + return self._get_anthropic_messages_api_base(api_base=api_base) + + 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 dynamic_api_key and "x-api-key" not in headers: + headers["x-api-key"] = dynamic_api_key + if "content-type" not in headers: + headers["content-type"] = "application/json" + + # DashScope's Anthropic-compatible endpoint does not accept Anthropic + # version/beta headers. Keep this provider-specific so other Anthropic + # Messages providers retain their existing behavior. + headers.pop("anthropic-version", None) + headers.pop("anthropic-beta", None) + return headers, api_base diff --git a/litellm/utils.py b/litellm/utils.py index e5ce7157e77..e6a4a041aef 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8301,6 +8301,12 @@ class ProviderConfigManager: ) return DeepSeekAnthropicMessagesConfig() + elif litellm.LlmProviders.DASHSCOPE == provider: + from litellm.llms.dashscope.messages.transformation import ( + DashScopeAnthropicMessagesConfig, + ) + + return DashScopeAnthropicMessagesConfig() elif litellm.LlmProviders.TENCENT == provider: from litellm.llms.tencent.messages.transformation import ( TencentAnthropicMessagesConfig, diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_messages_transformation.py b/tests/test_litellm/llms/dashscope/test_dashscope_messages_transformation.py new file mode 100644 index 00000000000..49f51d1ec93 --- /dev/null +++ b/tests/test_litellm/llms/dashscope/test_dashscope_messages_transformation.py @@ -0,0 +1,92 @@ +""" +Unit tests for DashScope Anthropic-compatible Messages support. +""" + +from litellm.llms.dashscope.messages.transformation import ( + DEFAULT_DASHSCOPE_ANTHROPIC_MESSAGES_API_BASE, + DashScopeAnthropicMessagesConfig, +) +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + + +def test_should_map_dashscope_compatible_base_to_anthropic_messages_endpoint(): + config = DashScopeAnthropicMessagesConfig() + + assert ( + config.get_complete_url( + api_base=None, + api_key=None, + model="qwen3-max", + optional_params={}, + litellm_params={}, + ) + == DEFAULT_DASHSCOPE_ANTHROPIC_MESSAGES_API_BASE + ) + assert ( + config.get_complete_url( + api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", + api_key=None, + model="qwen3-max", + optional_params={}, + litellm_params={}, + ) + == "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages" + ) + assert ( + config.get_complete_url( + api_base="https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + api_key=None, + model="qwen3-max", + optional_params={}, + litellm_params={}, + ) + == "https://dashscope-intl.aliyuncs.com/apps/anthropic/v1/messages" + ) + assert ( + config.get_complete_url( + api_base="https://dashscope.aliyuncs.com/apps/anthropic", + api_key=None, + model="qwen3-max", + optional_params={}, + litellm_params={}, + ) + == "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages" + ) + + +def test_should_prepare_dashscope_anthropic_messages_headers(): + config = DashScopeAnthropicMessagesConfig() + + headers, api_base = config.validate_anthropic_messages_environment( + headers={ + "anthropic-version": "2023-06-01", + "anthropic-beta": "prompt-caching-2024-07-31", + }, + model="qwen3-max", + messages=[{"role": "user", "content": "hello"}], + optional_params={}, + litellm_params={}, + api_key="dashscope-key", + api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", + ) + + assert headers["x-api-key"] == "dashscope-key" + assert headers["content-type"] == "application/json" + assert "anthropic-version" not in headers + assert "anthropic-beta" not in headers + assert api_base == "https://dashscope.aliyuncs.com/compatible-mode/v1" + + +def test_should_route_dashscope_provider_to_native_messages_config(): + qwen_max_config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="qwen3-max", + provider=LlmProviders.DASHSCOPE, + ) + qwen_plus_config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="qwen-plus", + provider=LlmProviders.DASHSCOPE, + ) + + assert isinstance(qwen_max_config, DashScopeAnthropicMessagesConfig) + assert isinstance(qwen_plus_config, DashScopeAnthropicMessagesConfig)