From d260909f04fb6ba5d45a9515b3147198d8f7af10 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:36:45 +0800 Subject: [PATCH] feat(dashscope): derive protocol bases from one domain Accept the DashScope domain root, the OpenAI-compatible base, the Anthropic Messages base, or a native /api/v1 base and derive every surface from it Adds a native Anthropic Messages config for /apps/anthropic so a single deployment serves chat, messages, embeddings, rerank, image generation, and video against the same credential Co-Authored-By: AaronHowell <111272993+AaronHowell@users.noreply.github.com> --- litellm/llms/dashscope/chat/transformation.py | 9 +- litellm/llms/dashscope/common_utils.py | 109 ++++++++++++-- .../llms/dashscope/embed/transformation.py | 4 +- .../image_generation/transformation.py | 16 ++- litellm/llms/dashscope/messages/__init__.py | 7 + .../llms/dashscope/messages/transformation.py | 68 +++++++++ .../llms/dashscope/videos/transformation.py | 5 + litellm/utils.py | 6 + .../test_dashscope_chat_transformation.py | 34 +++++ .../dashscope/test_dashscope_common_utils.py | 136 ++++++++++++++++++ ...test_dashscope_embedding_transformation.py | 34 +++++ .../test_dashscope_messages_transformation.py | 85 +++++++++++ .../test_dashscope_rerank_transformation.py | 7 + .../test_dashscope_video_transformation.py | 14 ++ tests/unit/test_dashscope_image_generation.py | 39 +++-- 15 files changed, 542 insertions(+), 31 deletions(-) create mode 100644 litellm/llms/dashscope/messages/__init__.py create mode 100644 litellm/llms/dashscope/messages/transformation.py create mode 100644 tests/unit/llms/dashscope/test_dashscope_common_utils.py create mode 100644 tests/unit/llms/dashscope/test_dashscope_messages_transformation.py diff --git a/litellm/llms/dashscope/chat/transformation.py b/litellm/llms/dashscope/chat/transformation.py index 966d1a01b8d..dfff9cec298 100644 --- a/litellm/llms/dashscope/chat/transformation.py +++ b/litellm/llms/dashscope/chat/transformation.py @@ -10,6 +10,7 @@ from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam from ...openai.chat.gpt_transformation import OpenAIGPTConfig +from ..common_utils import resolve_dashscope_openai_api_base class DashScopeChatConfig(OpenAIGPTConfig): @@ -55,11 +56,9 @@ class DashScopeChatConfig(OpenAIGPTConfig): def _get_openai_compatible_provider_info( self, api_base: str | None, api_key: str | None ) -> tuple[str | None, str | None]: - api_base = ( - api_base or get_secret_str("DASHSCOPE_API_BASE") or "https://dashscope.aliyuncs.com/compatible-mode/v1" - ) + resolved_api_base: Final = resolve_dashscope_openai_api_base(api_base) dynamic_api_key: Final = api_key or get_secret_str("DASHSCOPE_API_KEY") - return api_base, dynamic_api_key + return resolved_api_base, dynamic_api_key def get_openai_compatible_provider_info( self, @@ -69,7 +68,7 @@ class DashScopeChatConfig(OpenAIGPTConfig): return self._get_openai_compatible_provider_info(api_base, api_key) def _resolve_chat_api_base(self, api_base: str | None) -> str: - return api_base or "https://dashscope.aliyuncs.com/compatible-mode/v1" + return resolve_dashscope_openai_api_base(api_base) def get_complete_url( self, diff --git a/litellm/llms/dashscope/common_utils.py b/litellm/llms/dashscope/common_utils.py index 3e2e718a469..11e2c99829f 100644 --- a/litellm/llms/dashscope/common_utils.py +++ b/litellm/llms/dashscope/common_utils.py @@ -2,6 +2,7 @@ Common utilities for the DashScope LLM provider. """ +from dataclasses import dataclass from typing import TYPE_CHECKING, Final from urllib.parse import urlparse @@ -19,25 +20,113 @@ if TYPE_CHECKING: from litellm.llms.base_llm.videos.transformation import BaseVideoConfig DASHSCOPE_CHAT_COMPATIBLE_PATH: Final = "/compatible-mode/v1" -DASHSCOPE_RERANK_PATH: Final = "/compatible-api/v1/reranks" +DASHSCOPE_ANTHROPIC_PATH: Final = "/apps/anthropic" +DASHSCOPE_NATIVE_V1_PATH: Final = "/api/v1" +DASHSCOPE_COMPATIBLE_API_PATH: Final = "/compatible-api/v1" +DASHSCOPE_RERANK_PATH: Final = f"{DASHSCOPE_COMPATIBLE_API_PATH}/reranks" +DASHSCOPE_IMAGE_PATH: Final = "/api/v1/services/aigc/multimodal-generation/generation" +DASHSCOPE_DEFAULT_ORIGIN: Final = "https://dashscope.aliyuncs.com" + +_STANDARD_DASHSCOPE_HOSTS: Final = frozenset( + { + "dashscope.aliyuncs.com", + "dashscope-intl.aliyuncs.com", + "dashscope-us.aliyuncs.com", + } +) +_WORKSPACE_HOST_SUFFIX: Final = ".maas.aliyuncs.com" +_EXCLUDED_WORKSPACE_PREFIXES: Final = ("trial.", "token-plan.") +_PROTOCOL_PATH_PREFIXES: Final = ( + DASHSCOPE_CHAT_COMPATIBLE_PATH, + DASHSCOPE_ANTHROPIC_PATH, + DASHSCOPE_NATIVE_V1_PATH, + DASHSCOPE_COMPATIBLE_API_PATH, +) -def _rerank_base_for_chat_shaped_base(api_base: str | None) -> str | None: - if api_base is None: +@dataclass(frozen=True, slots=True) +class DashScopeEndpoints: + origin: str + openai: str + anthropic: str + native: str + + +def _is_dashscope_host(host: str) -> bool: + if host in _STANDARD_DASHSCOPE_HOSTS: + return True + return host.endswith(_WORKSPACE_HOST_SUFFIX) and not host.startswith(_EXCLUDED_WORKSPACE_PREFIXES) + + +def _dashscope_origin(api_base: str | None) -> str | None: + if not api_base: return None parsed: Final = urlparse(api_base) - host: Final = parsed.hostname or "" - on_aliyun_host: Final = host == "aliyuncs.com" or host.endswith(".aliyuncs.com") - if not on_aliyun_host or parsed.path.rstrip("/") != DASHSCOPE_CHAT_COMPATIBLE_PATH: + if parsed.scheme not in ("http", "https") or not parsed.netloc: return None - return f"{parsed.scheme}://{parsed.netloc}{DASHSCOPE_RERANK_PATH}" + if parsed.query or parsed.fragment: + return None + host: Final = (parsed.hostname or "").lower() + if not _is_dashscope_host(host): + return None + path: Final = parsed.path.rstrip("/") + if path and not any(path == prefix or path.startswith(f"{prefix}/") for prefix in _PROTOCOL_PATH_PREFIXES): + return None + return f"{parsed.scheme}://{parsed.netloc}" + + +def resolve_dashscope_endpoints(api_base: str | None) -> DashScopeEndpoints | None: + """ + Derive DashScope's protocol-specific bases from any standard DashScope base. + + Accepts the domain root, an OpenAI-compatible base, an Anthropic Messages + base, or a native `/api/v1` base for `dashscope.aliyuncs.com`, + `dashscope-intl.aliyuncs.com`, `dashscope-us.aliyuncs.com`, and + `{workspace}.{region}.maas.aliyuncs.com`. Returns None for any other host + or path so callers keep the caller-supplied base untouched. + """ + origin: Final = _dashscope_origin(api_base) + if origin is None: + return None + return DashScopeEndpoints( + origin=origin, + openai=f"{origin}{DASHSCOPE_CHAT_COMPATIBLE_PATH}", + anthropic=f"{origin}{DASHSCOPE_ANTHROPIC_PATH}", + native=f"{origin}{DASHSCOPE_NATIVE_V1_PATH}", + ) + + +def resolve_dashscope_openai_api_base( + api_base: str | None, + env_var: str = "DASHSCOPE_API_BASE", + default: str = f"{DASHSCOPE_DEFAULT_ORIGIN}{DASHSCOPE_CHAT_COMPATIBLE_PATH}", +) -> str: + candidate: Final = api_base or get_secret_str(env_var) + endpoints: Final = resolve_dashscope_endpoints(candidate) + if endpoints is not None: + return endpoints.openai + return candidate or default + + +def resolve_dashscope_anthropic_api_base( + api_base: str | None, + env_var: str = "DASHSCOPE_API_BASE", + default: str = f"{DASHSCOPE_DEFAULT_ORIGIN}{DASHSCOPE_ANTHROPIC_PATH}", +) -> str: + candidate: Final = api_base or get_secret_str(env_var) + endpoints: Final = resolve_dashscope_endpoints(candidate) + if endpoints is not None: + return endpoints.anthropic + return candidate or default def resolve_dashscope_family_rerank_api_base(api_base: str | None, env_var: str, default_rerank_base: str) -> str: - remapped: Final = _rerank_base_for_chat_shaped_base(api_base) - if api_base is not None and remapped is None: + endpoints: Final = resolve_dashscope_endpoints(api_base) + if endpoints is not None: + return get_secret_str(env_var) or f"{endpoints.origin}{DASHSCOPE_RERANK_PATH}" + if api_base is not None: return api_base - return get_secret_str(env_var) or remapped or default_rerank_base + return get_secret_str(env_var) or default_rerank_base def get_dashscope_family_embedding_config(custom_llm_provider: str) -> "BaseEmbeddingConfig": diff --git a/litellm/llms/dashscope/embed/transformation.py b/litellm/llms/dashscope/embed/transformation.py index d0c2b4dff0c..6b29f8ae1e1 100644 --- a/litellm/llms/dashscope/embed/transformation.py +++ b/litellm/llms/dashscope/embed/transformation.py @@ -23,7 +23,7 @@ from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues from litellm.types.utils import EmbeddingResponse, Usage -from ..common_utils import DashScopeError +from ..common_utils import DashScopeError, resolve_dashscope_openai_api_base DEFAULT_API_BASE: Final = "https://dashscope.aliyuncs.com/compatible-mode/v1" @@ -72,7 +72,7 @@ class DashScopeEmbeddingConfig(BaseEmbeddingConfig): return resolved_api_key def _resolve_embedding_api_base(self, api_base: str | None) -> str: - return api_base or get_secret_str("DASHSCOPE_API_BASE") or DEFAULT_API_BASE + return resolve_dashscope_openai_api_base(api_base, default=DEFAULT_API_BASE) def validate_environment( self, diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index 6a172e1e074..cfd6b78916e 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -39,6 +39,12 @@ from litellm.types.llms.openai import ( ) from litellm.types.utils import ImageObject, ImageResponse +from ..common_utils import ( + DASHSCOPE_CHAT_COMPATIBLE_PATH, + DASHSCOPE_IMAGE_PATH, + resolve_dashscope_endpoints, +) + if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer @@ -52,8 +58,6 @@ _JSON_OBJECTS: Final = TypeAdapter(Iterable[Mapping[str, object]], config=Config DEFAULT_API_BASE: Final = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" -CHAT_COMPATIBLE_MODE_PATH: Final = "/compatible-mode/v1" - # Maps OpenAI size strings (WxH) to DashScope size strings (W*H) OPENAI_TO_DASHSCOPE_SIZE: Final[dict] = { "256x256": "256*256", @@ -113,8 +117,14 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): litellm_params: dict, stream: bool | None = None, ) -> str: + endpoints: Final = resolve_dashscope_endpoints(api_base) + if endpoints is not None: + return f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}" + image_api_base: Final = ( - api_base if api_base and not api_base.rstrip("/").endswith(CHAT_COMPATIBLE_MODE_PATH) else None + None + if api_base and api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH) + else api_base ) return self._resolve_image_api_base(image_api_base) diff --git a/litellm/llms/dashscope/messages/__init__.py b/litellm/llms/dashscope/messages/__init__.py new file mode 100644 index 00000000000..08cd184404c --- /dev/null +++ b/litellm/llms/dashscope/messages/__init__.py @@ -0,0 +1,7 @@ +""" +DashScope Anthropic-compatible Messages Module +""" + +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..d46fe06f1f7 --- /dev/null +++ b/litellm/llms/dashscope/messages/transformation.py @@ -0,0 +1,68 @@ +""" +DashScope Anthropic-compatible Messages transformation config. +""" + +from typing import Any # noqa: TID251 # override below must mirror the legacy base signature + +import litellm +from litellm.litellm_core_utils.url_utils import ensure_api_route +from litellm.llms.anthropic.pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) +from litellm.secret_managers.main import get_secret_str + +from ..common_utils import resolve_dashscope_anthropic_api_base + + +class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): + """ + DashScope exposes an Anthropic-compatible Messages API at + ``{origin}/apps/anthropic`` for every DashScope domain, including + ``dashscope.aliyuncs.com`` and ``{workspace}.{region}.maas.aliyuncs.com``. + """ + + @property + def custom_llm_provider(self) -> str | None: + return "dashscope" + + def should_strip_billing_metadata(self) -> bool: + return True + + @staticmethod + def get_api_key(api_key: str | None = None) -> str | None: + return api_key or get_secret_str("DASHSCOPE_API_KEY") or litellm.api_key + + @staticmethod + def get_api_base(api_base: str | None = None) -> str: + return resolve_dashscope_anthropic_api_base(api_base) + + def validate_anthropic_messages_environment( + self, + headers: dict, # mutable-ok: mirrors the legacy base override signature + model: str, + messages: list[Any], # mutable-ok: mirrors the legacy base override signature + optional_params: dict, # mutable-ok: mirrors the legacy base override signature + litellm_params: dict, # mutable-ok: mirrors the legacy base override signature + api_key: str | None = None, + api_base: str | None = None, + ) -> tuple[dict, str | None]: # mutable-ok: mirrors the legacy base override signature + return super().validate_anthropic_messages_environment( + headers=headers, + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + api_key=self.get_api_key(api_key=api_key), + api_base=api_base, + ) + + def get_complete_url( + self, + api_base: str | None, + api_key: str | None, + model: str, + optional_params: dict, # mutable-ok: mirrors the base get_complete_url signature + litellm_params: dict, # mutable-ok: mirrors the base get_complete_url signature + stream: bool | None = None, + ) -> str: + return ensure_api_route(self.get_api_base(api_base=api_base), "v1/messages") diff --git a/litellm/llms/dashscope/videos/transformation.py b/litellm/llms/dashscope/videos/transformation.py index 5c738423a5a..59e42e7b926 100644 --- a/litellm/llms/dashscope/videos/transformation.py +++ b/litellm/llms/dashscope/videos/transformation.py @@ -38,6 +38,8 @@ from litellm.types.videos.utils import ( ) from litellm.utils import get_model_info +from ..common_utils import resolve_dashscope_endpoints + if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler @@ -206,6 +208,9 @@ def _parse_task_response(raw_response: httpx.Response) -> _TaskResponse: def _normalized_api_base(api_base: str) -> str: + endpoints: Final = resolve_dashscope_endpoints(api_base) + if endpoints is not None: + return endpoints.origin trimmed: Final = api_base.rstrip("/") if trimmed.endswith(DASHSCOPE_COMPATIBLE_MODE_PATH): return trimmed[: -len(DASHSCOPE_COMPATIBLE_MODE_PATH)] diff --git a/litellm/utils.py b/litellm/utils.py index 7de7ce6188d..9f440557268 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9720,6 +9720,12 @@ class ProviderConfigManager: ) return TencentAnthropicMessagesConfig() + elif litellm.LlmProviders.DASHSCOPE == provider: + from litellm.llms.dashscope.messages.transformation import ( + DashScopeAnthropicMessagesConfig, + ) + + return DashScopeAnthropicMessagesConfig() elif litellm.LlmProviders.GITHUB_COPILOT == provider: if "claude" in model_lower: from litellm.llms.github_copilot.messages.transformation import ( diff --git a/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py b/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py index a3fd5933a83..746f15ceb56 100644 --- a/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py @@ -14,6 +14,40 @@ import litellm from litellm import completion from litellm.llms.dashscope.chat.transformation import DashScopeChatConfig +WORKSPACE = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" + + +def test_get_complete_url_derives_the_compatible_base_from_any_standard_base(): + config = DashScopeChatConfig() + + for api_base in ( + WORKSPACE, + f"{WORKSPACE}/compatible-mode/v1", + f"{WORKSPACE}/apps/anthropic", + f"{WORKSPACE}/api/v1", + ): + url = config.get_complete_url( + api_base=api_base, + api_key="sk-test", + model="qwen3.7-plus", + optional_params={}, + litellm_params={}, + ) + + assert url == f"{WORKSPACE}/compatible-mode/v1/chat/completions" + + +def test_get_complete_url_keeps_unrecognized_hosts(): + url = DashScopeChatConfig().get_complete_url( + api_base="https://gateway.example.com/v1", + api_key="sk-test", + model="qwen3.7-plus", + optional_params={}, + litellm_params={}, + ) + + assert url == "https://gateway.example.com/v1/chat/completions" + class TestDashScopeConfig: """Test class for DashScope functionality""" diff --git a/tests/unit/llms/dashscope/test_dashscope_common_utils.py b/tests/unit/llms/dashscope/test_dashscope_common_utils.py new file mode 100644 index 00000000000..332ae293a99 --- /dev/null +++ b/tests/unit/llms/dashscope/test_dashscope_common_utils.py @@ -0,0 +1,136 @@ +""" +Unit tests for DashScope's protocol-specific base URL derivation. +""" + +import pytest + +from litellm.llms.dashscope.common_utils import ( + DASHSCOPE_DEFAULT_ORIGIN, + resolve_dashscope_anthropic_api_base, + resolve_dashscope_endpoints, + resolve_dashscope_family_rerank_api_base, + resolve_dashscope_openai_api_base, +) + +WORKSPACE = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" + + +@pytest.mark.parametrize( + "api_base", + [ + WORKSPACE, + f"{WORKSPACE}/", + f"{WORKSPACE}/compatible-mode/v1", + f"{WORKSPACE}/compatible-mode/v1/chat/completions", + f"{WORKSPACE}/apps/anthropic", + f"{WORKSPACE}/apps/anthropic/v1/messages", + f"{WORKSPACE}/api/v1", + f"{WORKSPACE}/api/v1/services/aigc/video-generation/video-synthesis", + ], +) +def test_workspace_base_derives_every_protocol(api_base: str): + endpoints = resolve_dashscope_endpoints(api_base) + + assert endpoints is not None + assert endpoints.origin == WORKSPACE + assert endpoints.openai == f"{WORKSPACE}/compatible-mode/v1" + assert endpoints.anthropic == f"{WORKSPACE}/apps/anthropic" + assert endpoints.native == f"{WORKSPACE}/api/v1" + + +@pytest.mark.parametrize( + "origin", + [ + "https://dashscope.aliyuncs.com", + "https://dashscope-intl.aliyuncs.com", + "https://dashscope-us.aliyuncs.com", + ], +) +@pytest.mark.parametrize("path", ["", "/", "/compatible-mode/v1"]) +def test_standard_hosts_derive_every_protocol(origin: str, path: str): + endpoints = resolve_dashscope_endpoints(f"{origin}{path}") + + assert endpoints is not None + assert endpoints.openai == f"{origin}/compatible-mode/v1" + assert endpoints.anthropic == f"{origin}/apps/anthropic" + assert endpoints.native == f"{origin}/api/v1" + + +@pytest.mark.parametrize( + "api_base", + [ + None, + "", + "not-a-url", + "https://gateway.example.com", + "https://gateway.example.com/compatible-mode/v1", + f"{WORKSPACE}/custom-path", + f"{WORKSPACE}/compatible-mode/v1?api-version=1", + "https://trial.cn-beijing.maas.aliyuncs.com", + "https://token-plan.cn-beijing.maas.aliyuncs.com", + ], +) +def test_unrecognized_bases_are_not_rewritten(api_base: str | None): + assert resolve_dashscope_endpoints(api_base) is None + + +def test_openai_base_derives_from_the_domain(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False) + + assert resolve_dashscope_openai_api_base(WORKSPACE) == f"{WORKSPACE}/compatible-mode/v1" + assert resolve_dashscope_openai_api_base(f"{WORKSPACE}/apps/anthropic") == f"{WORKSPACE}/compatible-mode/v1" + + +def test_openai_base_keeps_unrecognized_hosts(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False) + + assert resolve_dashscope_openai_api_base("https://gateway.example.com/v1") == "https://gateway.example.com/v1" + + +def test_openai_base_defaults_to_dashscope(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False) + + assert resolve_dashscope_openai_api_base(None) == f"{DASHSCOPE_DEFAULT_ORIGIN}/compatible-mode/v1" + + +def test_anthropic_base_derives_from_the_domain(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False) + + assert resolve_dashscope_anthropic_api_base(WORKSPACE) == f"{WORKSPACE}/apps/anthropic" + assert resolve_dashscope_anthropic_api_base(f"{WORKSPACE}/compatible-mode/v1") == f"{WORKSPACE}/apps/anthropic" + + +def test_anthropic_base_keeps_unrecognized_hosts(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False) + + assert ( + resolve_dashscope_anthropic_api_base("https://gateway.example.com/anthropic") + == "https://gateway.example.com/anthropic" + ) + + +def test_rerank_base_derives_from_the_domain(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DASHSCOPE_API_BASE_RERANK", raising=False) + + assert ( + resolve_dashscope_family_rerank_api_base(WORKSPACE, "DASHSCOPE_API_BASE_RERANK", "https://default/reranks") + == f"{WORKSPACE}/compatible-api/v1/reranks" + ) + + +def test_rerank_base_keeps_unrecognized_hosts(): + gateway = "https://gateway.example.com/v1" + + assert ( + resolve_dashscope_family_rerank_api_base(gateway, "DASHSCOPE_API_BASE_RERANK", "https://default/reranks") + == gateway + ) + + +def test_rerank_base_uses_the_configured_default(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DASHSCOPE_API_BASE_RERANK", raising=False) + + assert ( + resolve_dashscope_family_rerank_api_base(None, "DASHSCOPE_API_BASE_RERANK", "https://default/reranks") + == "https://default/reranks" + ) diff --git a/tests/unit/llms/dashscope/test_dashscope_embedding_transformation.py b/tests/unit/llms/dashscope/test_dashscope_embedding_transformation.py index 1b6eea0e4c8..5a161f2e088 100644 --- a/tests/unit/llms/dashscope/test_dashscope_embedding_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_embedding_transformation.py @@ -16,6 +16,40 @@ from litellm.llms.dashscope.embed.transformation import ( ) from litellm.types.utils import EmbeddingResponse +WORKSPACE = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" + + +def test_get_complete_url_derives_the_compatible_base_from_any_standard_base(): + config = DashScopeEmbeddingConfig() + + for api_base in ( + WORKSPACE, + f"{WORKSPACE}/compatible-mode/v1", + f"{WORKSPACE}/apps/anthropic", + f"{WORKSPACE}/api/v1", + ): + url = config.get_complete_url( + api_base=api_base, + api_key="sk-test", + model="text-embedding-v4", + optional_params={}, + litellm_params={}, + ) + + assert url == f"{WORKSPACE}/compatible-mode/v1/embeddings" + + +def test_get_complete_url_keeps_unrecognized_hosts(): + url = DashScopeEmbeddingConfig().get_complete_url( + api_base="https://gateway.example.com/v1", + api_key="sk-test", + model="text-embedding-v4", + optional_params={}, + litellm_params={}, + ) + + assert url == "https://gateway.example.com/v1/embeddings" + def test_validate_environment_and_url(): config = DashScopeEmbeddingConfig() diff --git a/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py new file mode 100644 index 00000000000..3151d9f1521 --- /dev/null +++ b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py @@ -0,0 +1,85 @@ +""" +Unit tests for DashScope's Anthropic-compatible Messages endpoint. +""" + +import pytest + +import litellm +from litellm.llms.dashscope.messages.transformation import ( + DashScopeAnthropicMessagesConfig, +) +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + +WORKSPACE = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" + + +@pytest.mark.parametrize( + ("api_base", "expected"), + [ + ( + None, + "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages", + ), + ( + "https://dashscope.aliyuncs.com", + "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages", + ), + ( + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages", + ), + ( + "https://dashscope.aliyuncs.com/apps/anthropic", + "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages", + ), + ( + "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages", + "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages", + ), + ( + WORKSPACE, + f"{WORKSPACE}/apps/anthropic/v1/messages", + ), + ( + f"{WORKSPACE}/compatible-mode/v1", + f"{WORKSPACE}/apps/anthropic/v1/messages", + ), + ( + "https://gateway.example.com/anthropic", + "https://gateway.example.com/anthropic/v1/messages", + ), + ], +) +def test_get_complete_url(api_base: str | None, expected: str): + config = DashScopeAnthropicMessagesConfig() + + url = config.get_complete_url( + api_base=api_base, + api_key="sk-test", + model="qwen3.7-plus", + optional_params={}, + litellm_params={}, + ) + + assert url == expected + + +def test_dashscope_resolves_to_the_native_messages_config(): + config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="qwen3.7-plus", + provider=LlmProviders.DASHSCOPE, + ) + + assert isinstance(config, DashScopeAnthropicMessagesConfig) + assert config.custom_llm_provider == "dashscope" + + +def test_dashscope_prefix_reaches_the_provider(): + model, provider, _, _ = litellm.get_llm_provider( + "dashscope/qwen3.7-plus", + api_base=WORKSPACE, + ) + + assert provider == "dashscope" + assert model == "qwen3.7-plus" diff --git a/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py b/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py index a913c79af31..8bac3f69ebf 100644 --- a/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py @@ -26,6 +26,13 @@ class TestDashScopeRerankURL: url = self.config.get_complete_url(api_base=None, model="qwen3-rerank") assert url == DEFAULT_RERANK_URL + def test_domain_root_remaps_to_rerank_route(self): + url = self.config.get_complete_url( + api_base="https://llm-abc123.cn-beijing.maas.aliyuncs.com", + model="qwen3-rerank", + ) + assert url == "https://llm-abc123.cn-beijing.maas.aliyuncs.com/compatible-api/v1/reranks" + def test_chat_shaped_base_remaps_to_rerank_route(self): url = self.config.get_complete_url( api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", diff --git a/tests/unit/llms/dashscope/test_dashscope_video_transformation.py b/tests/unit/llms/dashscope/test_dashscope_video_transformation.py index 15b77aaee3d..04ed634b379 100644 --- a/tests/unit/llms/dashscope/test_dashscope_video_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_video_transformation.py @@ -1119,6 +1119,20 @@ class TestDashScopeVideoEnvironment: assert url == "https://ws-123.cn-beijing.maas.aliyuncs.com" + def test_every_protocol_base_normalizes_to_the_host(self): + workspace = "https://ws-123.cn-beijing.maas.aliyuncs.com" + + for api_base in ( + f"{workspace}/compatible-mode/v1", + f"{workspace}/apps/anthropic", + f"{workspace}/api/v1", + ): + url = DashScopeVideoConfig().get_complete_url( + model="wan3.0-video", api_base=api_base, litellm_params={} + ) + + assert url == workspace + def test_default_host_is_beijing(self, monkeypatch): monkeypatch.delenv("DASHSCOPE_API_BASE_VIDEO", raising=False) monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False) diff --git a/tests/unit/test_dashscope_image_generation.py b/tests/unit/test_dashscope_image_generation.py index 960807a033b..a206ae001cf 100644 --- a/tests/unit/test_dashscope_image_generation.py +++ b/tests/unit/test_dashscope_image_generation.py @@ -12,14 +12,17 @@ import pytest from pydantic import ValidationError import litellm +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.dashscope.common_utils import ( + DASHSCOPE_DEFAULT_ORIGIN, + DASHSCOPE_IMAGE_PATH, +) from litellm.llms.dashscope.image_generation.transformation import ( - DashScopeImageGenerationConfig, DEFAULT_API_BASE, + DashScopeImageGenerationConfig, ) from litellm.types.utils import ImageResponse from litellm.utils import get_llm_provider -from litellm.llms.base_llm.chat.transformation import BaseLLMException - # --------------------------------------------------------------------------- # 1. Provider detection @@ -65,17 +68,31 @@ class TestDashScopeImageGenerationConfig: assert url == custom @pytest.mark.parametrize( - "chat_api_base", + ("api_base", "expected"), [ - "https://dashscope.aliyuncs.com/compatible-mode/v1", - "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/", + ( + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + ), + ( + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/", + DEFAULT_API_BASE, + ), + ( + "https://llm-abc123.cn-beijing.maas.aliyuncs.com", + "https://llm-abc123.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + ), + ( + "https://llm-abc123.cn-beijing.maas.aliyuncs.com/apps/anthropic", + "https://llm-abc123.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + ), ], ) - def test_get_complete_url_ignores_chat_compatible_mode_base( - self, chat_api_base: str + def test_get_complete_url_derives_from_the_domain( + self, api_base: str, expected: str ): - url = self.cfg.get_complete_url(chat_api_base, None, "qwen-image-3.0", {}, {}) - assert url == DEFAULT_API_BASE + url = self.cfg.get_complete_url(api_base, None, "qwen-image-3.0", {}, {}) + assert url == expected def test_validate_environment_sets_auth_header(self): headers = self.cfg.validate_environment( @@ -447,7 +464,7 @@ def test_litellm_image_generation_dashscope_end_to_end(model: str): called_url = ( call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "") ) - assert called_url == DEFAULT_API_BASE + assert called_url == f"{DASHSCOPE_DEFAULT_ORIGIN}{DASHSCOPE_IMAGE_PATH}" # Verify request body contains DashScope format call_kwargs = call_args[1] if call_args[1] else {}