From c98641fd5ac1093c9196462a968dc634c6f70c94 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:08:18 +0800 Subject: [PATCH] feat(dashscope)!: derive protocol bases from one domain (#47812) * 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> * fix(dashscope): keep Anthropic credentials off DashScope messages Require a DashScope key or a caller auth header before the Messages path runs, and opt out of workload identity, so the base class cannot fall back to ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN Also format the image generation base selection * style(dashscope): keep the messages config within the format gate * test(dashscope): cover caller Authorization on messages * fix(dashscope): keep the chat bridge for protocol-named bases and the image default for unconfigured calls * fix(dashscope): keep configured image and rerank bases, normalize blank keys, derive brand chat bases - image calls fall back to the provider's own chat base env (DASHSCOPE_API_BASE, QWENCLOUD_API_BASE, QWEN_AI_PLATFORM_API_BASE) before the image default - an explicit /compatible-api/v1/reranks URL wins over DASHSCOPE_API_BASE_RERANK again - a blank DashScope key counts as unset on /v1/messages, so it never falls back to ANTHROPIC_API_KEY - QwenCloud and Qianwen AI Platform chat and embeddings derive their compatible-mode base from a domain root - tests annotate new variables with Final and drop the config-class assertion * fix(dashscope): keep an explicit compatible-api rerank base as given * fix(dashscope): derive chat from compatible-api bases, keep the caller's rerank base, and let explicit bases beat per-endpoint env vars * fix(dashscope): keep a full DashScope image endpoint URL as given and type the brand tests * fix(dashscope): let the image env var beat any shared DashScope base * fix(dashscope): keep a full DASHSCOPE_API_BASE image URL ahead of the image env var * fix(dashscope): keep a native DashScope endpoint URL as given for chat and the api base header --------- Co-authored-by: AaronHowell <111272993+AaronHowell@users.noreply.github.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/images/main.py | 12 +- litellm/llms/dashscope/chat/transformation.py | 9 +- litellm/llms/dashscope/common_utils.py | 136 ++++++++++- .../llms/dashscope/embed/transformation.py | 4 +- .../image_generation/transformation.py | 35 ++- litellm/llms/dashscope/messages/__init__.py | 7 + .../llms/dashscope/messages/transformation.py | 96 ++++++++ litellm/llms/dashscope/qwen_ai_platform.py | 16 +- litellm/llms/dashscope/qwencloud.py | 16 +- .../llms/dashscope/videos/transformation.py | 5 + litellm/rerank_api/main.py | 12 +- litellm/utils.py | 6 + .../test_websearch_short_circuit.py | 1 + .../test_dashscope_chat_transformation.py | 37 ++- .../dashscope/test_dashscope_common_utils.py | 171 ++++++++++++++ ...test_dashscope_embedding_transformation.py | 35 +++ .../test_dashscope_messages_transformation.py | 215 ++++++++++++++++++ .../test_dashscope_rerank_transformation.py | 52 +++++ .../test_dashscope_video_transformation.py | 14 ++ .../llms/dashscope/test_qwen_brand_aliases.py | 46 ++++ tests/unit/test_dashscope_image_generation.py | 127 ++++++++++- 21 files changed, 1015 insertions(+), 37 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/images/main.py b/litellm/images/main.py index b3247ff938b..4aad5210028 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -68,6 +68,11 @@ from litellm.utils import ( ) _PROVIDER_ROUTING_HINTS: Final = TypeAdapter(ProviderRoutingHints) +_PROVIDERS_RESOLVING_THEIR_OWN_IMAGE_BASE: Final = ( + LlmProviders.DASHSCOPE, + LlmProviders.QWENCLOUD, + LlmProviders.QWEN_AI_PLATFORM, +) # Cache for ImageEditRequestUtils to avoid repeated __getattr__ calls _ImageEditRequestUtils_cache: type["ImageEditRequestUtils"] | None = None @@ -271,6 +276,7 @@ def image_generation( """ try: args: Final = locals() + requested_api_base: Final = api_base aimg_generation: Final = kwargs.get("aimg_generation", False) litellm_call_id: Final = kwargs.get("litellm_call_id", None) logger_fn: Final = kwargs.get("logger_fn", None) @@ -468,7 +474,11 @@ def image_generation( raise ValueError(f"image generation config is not supported for {custom_llm_provider}") # Resolve api_base from litellm.api_base if not explicitly provided - _api_base: Final = api_base or litellm.api_base + _api_base: Final = ( + requested_api_base + if custom_llm_provider in _PROVIDERS_RESOLVING_THEIR_OWN_IMAGE_BASE + else api_base or litellm.api_base + ) litellm_params_dict["api_base"] = _api_base return llm_http_handler.image_generation_handler( 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..5ad2aa17a3a 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,10 +20,123 @@ 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: +@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) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return None + 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 is_dashscope_native_endpoint_url(api_base: str) -> bool: + return urlparse(api_base).path.rstrip("/").startswith(f"{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) + if candidate and is_dashscope_native_endpoint_url(candidate): + return candidate + 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 dashscope_base_selects_anthropic_messages(api_base: str | None) -> bool: + candidate: Final = api_base or get_secret_str("DASHSCOPE_API_BASE") + if not candidate: + return False + path: Final = urlparse(candidate).path.rstrip("/") + if path.endswith(DASHSCOPE_ANTHROPIC_PATH) or f"{DASHSCOPE_ANTHROPIC_PATH}/" in path: + return True + return not path and resolve_dashscope_endpoints(candidate) is not None + + +def _chat_shaped_aliyun_origin(api_base: str | None) -> str | None: if api_base is None: return None parsed: Final = urlparse(api_base) @@ -30,14 +144,24 @@ def _rerank_base_for_chat_shaped_base(api_base: str | None) -> str | None: 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: return None - return f"{parsed.scheme}://{parsed.netloc}{DASHSCOPE_RERANK_PATH}" + return f"{parsed.scheme}://{parsed.netloc}" + + +def _is_compatible_api_base(api_base: str) -> bool: + path: Final = urlparse(api_base).path.rstrip("/") + return path == DASHSCOPE_COMPATIBLE_API_PATH or path.startswith(f"{DASHSCOPE_COMPATIBLE_API_PATH}/") 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: + if api_base is None: + return get_secret_str(env_var) or default_rerank_base + if _is_compatible_api_base(api_base): return api_base - return get_secret_str(env_var) or remapped or default_rerank_base + chat_origin: Final = _chat_shaped_aliyun_origin(api_base) + if chat_origin is not None: + return get_secret_str(env_var) or f"{chat_origin}{DASHSCOPE_RERANK_PATH}" + endpoints: Final = resolve_dashscope_endpoints(api_base) + return f"{endpoints.origin}{DASHSCOPE_RERANK_PATH}" if endpoints is not None else api_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 35132346261..0f01a4b3172 100644 --- a/litellm/llms/dashscope/embed/transformation.py +++ b/litellm/llms/dashscope/embed/transformation.py @@ -25,7 +25,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" @@ -91,7 +91,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 d1faa6c6cd0..2f528b5a2c3 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -39,6 +39,13 @@ from litellm.types.llms.openai import ( ) from litellm.types.utils import ImageObject, ImageResponse +from ..common_utils import ( + DASHSCOPE_CHAT_COMPATIBLE_PATH, + DASHSCOPE_IMAGE_PATH, + is_dashscope_native_endpoint_url, + 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 @@ -53,8 +60,6 @@ _DECODED_JSON: Final = TypeAdapter(object) 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", @@ -102,8 +107,14 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): raise ValueError("DASHSCOPE_API_KEY is not set") return resolved_api_key - def _resolve_image_api_base(self, image_api_base: str | None) -> str: - return image_api_base or get_secret_str("DASHSCOPE_API_BASE_IMAGE") or DEFAULT_API_BASE + def _image_api_base_env(self) -> str | None: + return get_secret_str("DASHSCOPE_API_BASE_IMAGE") + + def _default_image_api_base(self) -> str: + return DEFAULT_API_BASE + + def _configured_chat_api_base(self) -> str | None: + return get_secret_str("DASHSCOPE_API_BASE") def get_complete_url( self, @@ -114,10 +125,18 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): litellm_params: dict, stream: bool | None = None, ) -> str: - image_api_base: Final = ( - api_base if api_base and not api_base.rstrip("/").endswith(CHAT_COMPATIBLE_MODE_PATH) else None - ) - return self._resolve_image_api_base(image_api_base) + image_env_api_base: Final = self._image_api_base_env() + configured_api_base: Final = api_base or self._configured_chat_api_base() + if not configured_api_base or configured_api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH): + return image_env_api_base or self._default_image_api_base() + if is_dashscope_native_endpoint_url(configured_api_base): + return configured_api_base + endpoints: Final = resolve_dashscope_endpoints(configured_api_base) + if endpoints is None: + return configured_api_base + if not api_base and image_env_api_base: + return image_env_api_base + return f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}" def validate_environment( self, 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..9d0d1db91c8 --- /dev/null +++ b/litellm/llms/dashscope/messages/transformation.py @@ -0,0 +1,96 @@ +""" +DashScope Anthropic-compatible Messages transformation config. +""" + +from typing import ClassVar, Final + +import litellm +from litellm.exceptions import AuthenticationError +from litellm.litellm_core_utils.url_utils import ensure_api_route +from litellm.llms.anthropic.messages.native.transformation import ( + AnthropicMessagesConfig, +) +from litellm.secret_managers.main import get_secret_str, normalize_nonempty_secret_str + +from ..common_utils import ( + dashscope_base_selects_anthropic_messages, + 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``. + """ + + _workload_identity_eligible: ClassVar[bool] = False + + @property + def custom_llm_provider(self) -> str | None: + return "dashscope" + + def should_strip_billing_metadata(self) -> bool: + return True + + def handles_web_search_natively(self) -> bool: + return False + + def serves_api_base(self, model: str, api_base: str | None) -> bool: + return dashscope_base_selects_anthropic_messages(api_base) + + @staticmethod + def get_api_key(api_key: str | None = None) -> str | None: + return ( + normalize_nonempty_secret_str(api_key) + or normalize_nonempty_secret_str(get_secret_str("DASHSCOPE_API_KEY")) + or normalize_nonempty_secret_str(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[str, str], # mutable-ok: mirrors the legacy base override signature + model: str, + messages: list[object], # mutable-ok: mirrors the legacy base override signature + optional_params: dict[str, object], # mutable-ok: mirrors the legacy base override signature + litellm_params: dict[str, object], # mutable-ok: mirrors the legacy base override signature + api_key: str | None = None, + api_base: str | None = None, + ) -> tuple[dict[str, str], str | None]: # mutable-ok: mirrors the base signature + resolved_api_key: Final = self.get_api_key(api_key=api_key) + if resolved_api_key is None: + present_headers: Final = {name.lower() for name in headers} + if "x-api-key" not in present_headers and "authorization" not in present_headers: + raise AuthenticationError( + message=( + "Missing DashScope API key. Set DASHSCOPE_API_KEY or pass api_key explicitly. " + "Anthropic credentials are never sent to a DashScope endpoint." + ), + llm_provider="dashscope", + model=model, + ) + return super().validate_anthropic_messages_environment( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportReturnType] # base signature uses an unparameterized dict + headers=headers, + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + api_key=resolved_api_key, + api_base=api_base, + ) + + def get_complete_url( + self, + api_base: str | None, + api_key: str | None, + model: str, + optional_params: dict[str, object], # mutable-ok: mirrors the base get_complete_url signature + litellm_params: dict[str, object], # 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/qwen_ai_platform.py b/litellm/llms/dashscope/qwen_ai_platform.py index f7cc50cc82a..db01c8a0a15 100644 --- a/litellm/llms/dashscope/qwen_ai_platform.py +++ b/litellm/llms/dashscope/qwen_ai_platform.py @@ -3,7 +3,7 @@ from typing import Final from litellm.secret_managers.main import get_secret_str from .chat.transformation import DashScopeChatConfig -from .common_utils import resolve_dashscope_family_rerank_api_base +from .common_utils import resolve_dashscope_family_rerank_api_base, resolve_dashscope_openai_api_base from .embed.transformation import DashScopeEmbeddingConfig from .image_generation.transformation import DashScopeImageGenerationConfig from .rerank.transformation import DashScopeRerankConfig @@ -38,7 +38,7 @@ class QwenAIPlatformChatConfig(DashScopeChatConfig): return self._resolve_chat_api_base(api_base), _resolve_qwen_ai_platform_api_key(api_key) def _resolve_chat_api_base(self, api_base: str | None) -> str: - return api_base or get_secret_str("QWEN_AI_PLATFORM_API_BASE") or QWEN_AI_PLATFORM_API_BASE + return resolve_dashscope_openai_api_base(api_base, "QWEN_AI_PLATFORM_API_BASE", QWEN_AI_PLATFORM_API_BASE) class QwenAIPlatformEmbeddingConfig(DashScopeEmbeddingConfig): @@ -46,7 +46,7 @@ class QwenAIPlatformEmbeddingConfig(DashScopeEmbeddingConfig): return _require_qwen_ai_platform_api_key(api_key) def _resolve_embedding_api_base(self, api_base: str | None) -> str: - return api_base or get_secret_str("QWEN_AI_PLATFORM_API_BASE") or QWEN_AI_PLATFORM_API_BASE + return resolve_dashscope_openai_api_base(api_base, "QWEN_AI_PLATFORM_API_BASE", QWEN_AI_PLATFORM_API_BASE) class QwenAIPlatformRerankConfig(DashScopeRerankConfig): @@ -63,8 +63,14 @@ class QwenAIPlatformImageGenerationConfig(DashScopeImageGenerationConfig): def _resolve_api_key(self, api_key: str | None) -> str: return _require_qwen_ai_platform_api_key(api_key) - def _resolve_image_api_base(self, image_api_base: str | None) -> str: - return image_api_base or get_secret_str("QWEN_AI_PLATFORM_API_BASE_IMAGE") or QWEN_AI_PLATFORM_IMAGE_API_BASE + def _image_api_base_env(self) -> str | None: + return get_secret_str("QWEN_AI_PLATFORM_API_BASE_IMAGE") + + def _default_image_api_base(self) -> str: + return QWEN_AI_PLATFORM_IMAGE_API_BASE + + def _configured_chat_api_base(self) -> str | None: + return get_secret_str("QWEN_AI_PLATFORM_API_BASE") class QwenAIPlatformVideoConfig(DashScopeVideoConfig): diff --git a/litellm/llms/dashscope/qwencloud.py b/litellm/llms/dashscope/qwencloud.py index a12e21b0983..6ae42c62e42 100644 --- a/litellm/llms/dashscope/qwencloud.py +++ b/litellm/llms/dashscope/qwencloud.py @@ -3,7 +3,7 @@ from typing import Final from litellm.secret_managers.main import get_secret_str from .chat.transformation import DashScopeChatConfig -from .common_utils import resolve_dashscope_family_rerank_api_base +from .common_utils import resolve_dashscope_family_rerank_api_base, resolve_dashscope_openai_api_base from .embed.transformation import DashScopeEmbeddingConfig from .image_generation.transformation import DashScopeImageGenerationConfig from .rerank.transformation import DashScopeRerankConfig @@ -38,7 +38,7 @@ class QwenCloudChatConfig(DashScopeChatConfig): return self._resolve_chat_api_base(api_base), _resolve_qwencloud_api_key(api_key) def _resolve_chat_api_base(self, api_base: str | None) -> str: - return api_base or get_secret_str("QWENCLOUD_API_BASE") or QWENCLOUD_API_BASE + return resolve_dashscope_openai_api_base(api_base, "QWENCLOUD_API_BASE", QWENCLOUD_API_BASE) class QwenCloudEmbeddingConfig(DashScopeEmbeddingConfig): @@ -46,7 +46,7 @@ class QwenCloudEmbeddingConfig(DashScopeEmbeddingConfig): return _require_qwencloud_api_key(api_key) def _resolve_embedding_api_base(self, api_base: str | None) -> str: - return api_base or get_secret_str("QWENCLOUD_API_BASE") or QWENCLOUD_API_BASE + return resolve_dashscope_openai_api_base(api_base, "QWENCLOUD_API_BASE", QWENCLOUD_API_BASE) class QwenCloudRerankConfig(DashScopeRerankConfig): @@ -63,8 +63,14 @@ class QwenCloudImageGenerationConfig(DashScopeImageGenerationConfig): def _resolve_api_key(self, api_key: str | None) -> str: return _require_qwencloud_api_key(api_key) - def _resolve_image_api_base(self, image_api_base: str | None) -> str: - return image_api_base or get_secret_str("QWENCLOUD_API_BASE_IMAGE") or QWENCLOUD_IMAGE_API_BASE + def _image_api_base_env(self) -> str | None: + return get_secret_str("QWENCLOUD_API_BASE_IMAGE") + + def _default_image_api_base(self) -> str: + return QWENCLOUD_IMAGE_API_BASE + + def _configured_chat_api_base(self) -> str | None: + return get_secret_str("QWENCLOUD_API_BASE") class QwenCloudVideoConfig(DashScopeVideoConfig): 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/rerank_api/main.py b/litellm/rerank_api/main.py index 65d9b89cf79..e344ce46be7 100644 --- a/litellm/rerank_api/main.py +++ b/litellm/rerank_api/main.py @@ -19,6 +19,12 @@ from litellm.types.rerank import RerankResponse from litellm.types.router import * from litellm.utils import ProviderConfigManager, client, exception_type +_PROVIDERS_RESOLVING_THEIR_OWN_RERANK_BASE: Final = ( + litellm.LlmProviders.DASHSCOPE, + litellm.LlmProviders.QWENCLOUD, + litellm.LlmProviders.QWEN_AI_PLATFORM, +) + ####### ENVIRONMENT VARIABLES ################### # Initialize any necessary instances or variables here together_rerank: Final = TogetherAIRerank() @@ -543,7 +549,11 @@ def rerank( logging_obj=litellm_logging_obj, timeout=optional_params.timeout, api_key=dynamic_api_key or optional_params.api_key, - api_base=dynamic_api_base or optional_params.api_base, + api_base=( + optional_params.api_base or dynamic_api_base + if _custom_llm_provider in _PROVIDERS_RESOLVING_THEIR_OWN_RERANK_BASE + else dynamic_api_base or optional_params.api_base + ), _is_async=_is_async, headers=headers or litellm.headers or {}, client=client, diff --git a/litellm/utils.py b/litellm/utils.py index 8ea4f6ceda0..4cd2335dd0c 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -10048,6 +10048,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/integrations/websearch_interception/test_websearch_short_circuit.py b/tests/unit/integrations/websearch_interception/test_websearch_short_circuit.py index 414bcc11602..ef050d96dc5 100644 --- a/tests/unit/integrations/websearch_interception/test_websearch_short_circuit.py +++ b/tests/unit/integrations/websearch_interception/test_websearch_short_circuit.py @@ -32,6 +32,7 @@ class TestTryShortCircuitSearch: ("github_copilot", "github_copilot/claude-sonnet-5.5"), ("databricks", "databricks/databricks-claude-sonnet-5-5"), ("databricks", "databricks/system.ai.claude-sonnet-5-5"), + ("dashscope", "dashscope/qwen3.7-plus"), ], ) async def test_short_circuits_single_web_search_tool(self, provider, model): diff --git a/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py b/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py index a3fd5933a83..99a8004b0a1 100644 --- a/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py @@ -5,15 +5,50 @@ These tests validate the DashScopeConfig class which extends OpenAIGPTConfig. DashScope is an OpenAI-compatible provider with minor customizations. """ +from typing import Final +import pytest from litellm.types.llms.openai import AllMessageValues -import pytest import litellm from litellm import completion from litellm.llms.dashscope.chat.transformation import DashScopeChatConfig +WORKSPACE: Final = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" + + +def test_get_complete_url_derives_the_compatible_base_from_any_standard_base(): + config: Final = DashScopeChatConfig() + + for api_base in ( + WORKSPACE, + f"{WORKSPACE}/compatible-mode/v1", + f"{WORKSPACE}/apps/anthropic", + f"{WORKSPACE}/api/v1", + ): + url: Final = 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: Final = 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..2dc4d5e5a85 --- /dev/null +++ b/tests/unit/llms/dashscope/test_dashscope_common_utils.py @@ -0,0 +1,171 @@ +""" +Unit tests for DashScope's protocol-specific base URL derivation. +""" + +from typing import Final + +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: Final = "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: Final = 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: Final = 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" + assert ( + resolve_dashscope_openai_api_base(f"{WORKSPACE}/compatible-api/v1/reranks") == f"{WORKSPACE}/compatible-mode/v1" + ) + + +@pytest.mark.parametrize( + "endpoint_url", + [ + "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + f"{WORKSPACE}/api/v1/services/aigc/video-generation/video-synthesis", + ], +) +def test_openai_base_keeps_a_native_endpoint_url(monkeypatch: pytest.MonkeyPatch, endpoint_url: str): + monkeypatch.setenv("DASHSCOPE_API_BASE", endpoint_url) + + assert resolve_dashscope_openai_api_base(None) == endpoint_url + assert resolve_dashscope_openai_api_base(endpoint_url) == endpoint_url + assert resolve_dashscope_openai_api_base(f"{WORKSPACE}/api/v1") == 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: Final = "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" + ) + + +@pytest.mark.parametrize( + "origin", + ["https://cn-hongkong.dashscope.aliyuncs.com", "https://token-plan.cn-beijing.maas.aliyuncs.com"], +) +def test_rerank_keeps_remapping_chat_bases_on_other_aliyun_hosts(monkeypatch: pytest.MonkeyPatch, origin: str): + monkeypatch.delenv("DASHSCOPE_API_BASE_RERANK", raising=False) + + assert ( + resolve_dashscope_family_rerank_api_base( + f"{origin}/compatible-mode/v1", "DASHSCOPE_API_BASE_RERANK", "https://default/reranks" + ) + == f"{origin}/compatible-api/v1/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..27afbee3173 100644 --- a/tests/unit/llms/dashscope/test_dashscope_embedding_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_embedding_transformation.py @@ -3,6 +3,7 @@ Unit tests for DashScope embedding transformation. """ import json +from typing import Final from unittest.mock import MagicMock import httpx @@ -16,6 +17,40 @@ from litellm.llms.dashscope.embed.transformation import ( ) from litellm.types.utils import EmbeddingResponse +WORKSPACE: Final = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" + + +def test_get_complete_url_derives_the_compatible_base_from_any_standard_base(): + config: Final = DashScopeEmbeddingConfig() + + for api_base in ( + WORKSPACE, + f"{WORKSPACE}/compatible-mode/v1", + f"{WORKSPACE}/apps/anthropic", + f"{WORKSPACE}/api/v1", + ): + url: Final = 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: Final = 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..decc12afd87 --- /dev/null +++ b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py @@ -0,0 +1,215 @@ +""" +Unit tests for DashScope's Anthropic-compatible Messages endpoint. +""" + +from typing import Final + +import pytest +import respx + +import litellm +from litellm.exceptions import AuthenticationError +from litellm.llms.dashscope.messages.transformation import ( + DashScopeAnthropicMessagesConfig, +) + +WORKSPACE: Final = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" +INTL: Final = "https://dashscope-intl.aliyuncs.com" +CODING_PLAN: Final = "https://coding-intl.dashscope.aliyuncs.com" +CHAT_COMPLETION: Final = { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1792227137, + "model": "qwen3.7-plus", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10}, +} +MESSAGE: Final = { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "qwen3.7-plus", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 9, "output_tokens": 1}, +} + + +@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: Final = DashScopeAnthropicMessagesConfig() + + url: Final = config.get_complete_url( + api_base=api_base, + api_key="sk-test", + model="qwen3.7-plus", + optional_params={}, + litellm_params={}, + ) + + assert url == expected + + +@pytest.mark.parametrize("dashscope_api_key", [None, " "]) +def test_missing_dashscope_key_does_not_fall_back_to_anthropic( + monkeypatch: pytest.MonkeyPatch, dashscope_api_key: str | None +): + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + if dashscope_api_key is not None: + monkeypatch.setenv("DASHSCOPE_API_KEY", dashscope_api_key) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-secret") + monkeypatch.setattr(litellm, "api_key", None) + headers: dict[str, str] = {} + + with pytest.raises(AuthenticationError) as error: + DashScopeAnthropicMessagesConfig().validate_anthropic_messages_environment( + headers=headers, + model="qwen3.7-plus", + messages=[], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=WORKSPACE, + ) + + assert "DashScope" in str(error.value) + assert "sk-ant-secret" not in str(error.value) + assert {name.lower() for name in headers}.isdisjoint({"x-api-key", "authorization"}) + + +def test_caller_auth_header_is_honored_without_a_dashscope_key(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + monkeypatch.setattr(litellm, "api_key", None) + headers: Final = {"x-api-key": "caller-key"} + + resolved_headers, _ = DashScopeAnthropicMessagesConfig().validate_anthropic_messages_environment( + headers=headers, + model="qwen3.7-plus", + messages=[], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=WORKSPACE, + ) + + assert resolved_headers["x-api-key"] == "caller-key" + + +def test_caller_authorization_header_is_honored_without_a_dashscope_key(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + monkeypatch.setattr(litellm, "api_key", None) + headers: Final = {"Authorization": "Bearer caller-token"} + + resolved_headers, _ = DashScopeAnthropicMessagesConfig().validate_anthropic_messages_environment( + headers=headers, + model="qwen3.7-plus", + messages=[], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=WORKSPACE, + ) + + assert resolved_headers["Authorization"] == "Bearer caller-token" + + +def test_dashscope_key_wins_over_an_anthropic_environment_key(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-dashscope") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-secret") + + resolved_headers, _ = DashScopeAnthropicMessagesConfig().validate_anthropic_messages_environment( + headers={}, + model="qwen3.7-plus", + messages=[], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=WORKSPACE, + ) + + header_values: Final = " ".join(resolved_headers.values()) + assert "sk-dashscope" in header_values + assert "sk-ant-secret" not in header_values + + +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" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("api_base", "expected_url"), + [ + (INTL, f"{INTL}/apps/anthropic/v1/messages"), + (f"{INTL}/apps/anthropic", f"{INTL}/apps/anthropic/v1/messages"), + (WORKSPACE, f"{WORKSPACE}/apps/anthropic/v1/messages"), + (f"{CODING_PLAN}/apps/anthropic", f"{CODING_PLAN}/apps/anthropic/v1/messages"), + (None, "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"), + (f"{INTL}/compatible-mode/v1", f"{INTL}/compatible-mode/v1/chat/completions"), + (f"{CODING_PLAN}/v1", f"{CODING_PLAN}/v1/chat/completions"), + ("https://gateway.example.com/anthropic", "https://gateway.example.com/anthropic/chat/completions"), + ], +) +async def test_only_a_domain_root_or_anthropic_base_leaves_the_chat_bridge( + monkeypatch: pytest.MonkeyPatch, api_base: str | None, expected_url: str +): + monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + + with respx.mock(assert_all_called=False) as upstream: + upstream.route(path__regex=r".*/chat/completions$").respond(200, json=CHAT_COMPLETION) + upstream.route(path__regex=r".*/v1/messages$").respond(200, json=MESSAGE) + + await litellm.anthropic.messages.acreate( + model="dashscope/qwen3.7-plus", + messages=[{"role": "user", "content": "say ok"}], + max_tokens=16, + api_key="sk-dashscope", + api_base=api_base, + ) + + assert [str(call.request.url) for call in upstream.calls] == [expected_url] diff --git a/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py b/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py index a913c79af31..f0dbdb74b68 100644 --- a/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py @@ -3,12 +3,15 @@ Unit tests for DashScope rerank transformation. """ import json +from typing import Final from unittest.mock import MagicMock import httpx import pytest +import respx from pydantic import ValidationError +import litellm from litellm.llms.dashscope.common_utils import DashScopeError from litellm.llms.dashscope.rerank.transformation import ( @@ -26,6 +29,21 @@ 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: Final = 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_explicit_rerank_url_wins_over_the_rerank_env(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("DASHSCOPE_API_BASE_RERANK", DEFAULT_RERANK_URL) + explicit_url: Final = "https://dashscope-intl.aliyuncs.com/compatible-api/v1/reranks" + + url: Final = self.config.get_complete_url(api_base=explicit_url, model="qwen3-rerank") + + assert url == explicit_url + 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", @@ -434,3 +452,37 @@ def test_transform_rerank_response_shape_errors_do_not_echo_the_payload(): _transform({"results": [["leaked document text"]]}) assert "leaked document text" not in str(exc_info.value) + + +@pytest.mark.parametrize( + ("provider", "rerank_env"), + [ + ("dashscope", "DASHSCOPE_API_BASE_RERANK"), + ("qwencloud", "QWENCLOUD_API_BASE_RERANK"), + ("qwen_ai_platform", "QWEN_AI_PLATFORM_API_BASE_RERANK"), + ], +) +@pytest.mark.parametrize( + "api_base", + [ + "https://dashscope-intl.aliyuncs.com/compatible-api/v1/reranks", + "https://dashscope-intl.aliyuncs.com/compatible-api/v1", + "https://dashscope-intl.aliyuncs.com", + "https://dashscope-intl.aliyuncs.com/api/v1", + ], +) +def test_rerank_sends_an_explicit_base_over_the_rerank_env( + monkeypatch: pytest.MonkeyPatch, provider: str, rerank_env: str, api_base: str +): + monkeypatch.setenv("DASHSCOPE_API_BASE", "https://dashscope-intl.aliyuncs.com") + monkeypatch.setenv(rerank_env, DEFAULT_RERANK_URL) + + with respx.mock(assert_all_called=True) as upstream: + upstream.post("https://dashscope-intl.aliyuncs.com/compatible-api/v1/reranks").respond( + 200, json={"id": "r1", "results": [{"index": 0, "relevance_score": 0.9}], "usage": {"total_tokens": 5}} + ) + response: Final = litellm.rerank( + model=f"{provider}/qwen3-rerank", query="q", documents=["a"], api_base=api_base, api_key="sk-test" + ) + + assert response.results == [{"index": 0, "relevance_score": 0.9}] diff --git a/tests/unit/llms/dashscope/test_dashscope_video_transformation.py b/tests/unit/llms/dashscope/test_dashscope_video_transformation.py index 15b77aaee3d..c1b4157c8ab 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: Final = "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: Final = 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/llms/dashscope/test_qwen_brand_aliases.py b/tests/unit/llms/dashscope/test_qwen_brand_aliases.py index 69ae66d3818..43ef8f90b39 100644 --- a/tests/unit/llms/dashscope/test_qwen_brand_aliases.py +++ b/tests/unit/llms/dashscope/test_qwen_brand_aliases.py @@ -2,8 +2,10 @@ import json import math import re from pathlib import Path +from typing import Final, Literal import pytest +from typing_extensions import ReadOnly, TypedDict import litellm from litellm import completion, get_llm_provider @@ -53,6 +55,23 @@ DASHSCOPE_FAMILY_ENV_VARS = [ "QWEN_AI_PLATFORM_API_BASE_IMAGE", ] + + +class BrandCase(TypedDict): + provider: ReadOnly[str] + display_name: ReadOnly[str] + enum: ReadOnly[LlmProviders] + key_env: ReadOnly[str] + base_env: ReadOnly[str] + default_base: ReadOnly[str] + default_rerank_base: ReadOnly[str] + default_image_base: ReadOnly[str] + chat_config: ReadOnly[type[DashScopeChatConfig]] + embedding_config: ReadOnly[type[DashScopeEmbeddingConfig]] + rerank_config: ReadOnly[type[DashScopeRerankConfig]] + image_config: ReadOnly[type[DashScopeImageGenerationConfig]] + + BRAND_CASES = [ pytest.param( { @@ -211,6 +230,20 @@ class TestQwenBrandDefaultUrls: ) assert url == f"{brand['default_base']}/embeddings" + @pytest.mark.parametrize("brand", BRAND_CASES) + @pytest.mark.parametrize(("config_key", "route"), [("chat_config", "chat/completions"), ("embedding_config", "embeddings")]) + def test_openai_routes_derive_from_a_domain_root( + self, brand: BrandCase, config_key: Literal["chat_config", "embedding_config"], route: str + ) -> None: + url: Final = brand[config_key]().get_complete_url( + api_base="https://dashscope-intl.aliyuncs.com", + api_key="sk-test", + model="qwen-max", + optional_params={}, + litellm_params={}, + ) + assert url == f"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/{route}" + @pytest.mark.parametrize("brand", BRAND_CASES) def test_rerank_complete_url(self, brand): url = brand["rerank_config"]().get_complete_url(api_base=None, model="gte-rerank-v2") @@ -249,6 +282,19 @@ class TestQwenBrandDefaultUrls: ) assert url == brand["default_image_base"] + @pytest.mark.parametrize("brand", BRAND_CASES) + def test_image_generation_uses_its_own_configured_base(self, monkeypatch: pytest.MonkeyPatch, brand: BrandCase) -> None: + monkeypatch.setenv("DASHSCOPE_API_BASE", "https://legacy.example.com/images") + monkeypatch.setenv(brand["base_env"], "https://gateway.example.com/qwen/images") + url: Final = brand["image_config"]().get_complete_url( + api_base=None, + api_key="sk-test", + model="qwen-image", + optional_params={}, + litellm_params={}, + ) + assert url == "https://gateway.example.com/qwen/images" + @pytest.mark.parametrize("brand", BRAND_CASES) def test_validate_environment_requires_key(self, brand): with pytest.raises(ValueError, match="DASHSCOPE_API_KEY"): diff --git a/tests/unit/test_dashscope_image_generation.py b/tests/unit/test_dashscope_image_generation.py index 960807a033b..9c65399350c 100644 --- a/tests/unit/test_dashscope_image_generation.py +++ b/tests/unit/test_dashscope_image_generation.py @@ -5,21 +5,26 @@ qwen-image-3.0, qwen-image-3.0-pro). Run in docker: pytest tests/unit/test_dashscope_image_generation.py -v """ +from typing import Final from unittest.mock import MagicMock, patch import httpx import pytest +import respx 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 @@ -77,6 +82,47 @@ class TestDashScopeImageGenerationConfig: url = self.cfg.get_complete_url(chat_api_base, None, "qwen-image-3.0", {}, {}) assert url == DEFAULT_API_BASE + @pytest.mark.parametrize( + ("api_base", "expected"), + [ + ( + "https://dashscope.aliyuncs.com", + "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + ), + ( + "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", + ), + ( + "https://dashscope-us.aliyuncs.com/api/v1/", + "https://dashscope-us.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + ), + ], + ) + def test_get_complete_url_derives_from_the_domain( + self, api_base: str, expected: str + ): + url: Final = self.cfg.get_complete_url(api_base, None, "qwen-image-3.0", {}, {}) + assert url == expected + + @pytest.mark.parametrize( + "endpoint_url", + [ + "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation", + "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis", + ], + ) + def test_get_complete_url_keeps_a_full_dashscope_endpoint_url( + self, monkeypatch: pytest.MonkeyPatch, endpoint_url: str + ): + monkeypatch.setenv("DASHSCOPE_API_BASE_IMAGE", "https://images.example.com/generate") + url: Final = self.cfg.get_complete_url(endpoint_url, None, "qwen-image-3.0", {}, {}) + assert url == endpoint_url + def test_validate_environment_sets_auth_header(self): headers = self.cfg.validate_environment( headers={}, @@ -458,6 +504,81 @@ def test_litellm_image_generation_dashscope_end_to_end(model: str): assert body["parameters"]["size"] == "1024*1024" + +@pytest.mark.parametrize( + ("api_base", "chat_base_env", "image_base_env", "expected_url"), + [ + (None, "", "", DEFAULT_API_BASE), + ("https://dashscope.aliyuncs.com", "", "", f"{DASHSCOPE_DEFAULT_ORIGIN}{DASHSCOPE_IMAGE_PATH}"), + (None, "", "https://images.example.com/generate", "https://images.example.com/generate"), + ( + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "", + "https://images.example.com/generate", + "https://images.example.com/generate", + ), + (None, "https://gateway.example.com/dashscope/images", "", "https://gateway.example.com/dashscope/images"), + (None, "https://dashscope-us.aliyuncs.com", "https://images.example.com/generate", "https://images.example.com/generate"), + ( + None, + f"https://dashscope-us.aliyuncs.com{DASHSCOPE_IMAGE_PATH}", + "https://images.example.com/generate", + f"https://dashscope-us.aliyuncs.com{DASHSCOPE_IMAGE_PATH}", + ), + ( + None, + "https://gateway.example.com/dashscope/images", + "https://images.example.com/generate", + "https://gateway.example.com/dashscope/images", + ), + ( + None, + "https://dashscope-us.aliyuncs.com/compatible-mode/v1", + "https://images.example.com/generate", + "https://images.example.com/generate", + ), + ( + "https://dashscope-us.aliyuncs.com", + "", + "https://images.example.com/generate", + f"https://dashscope-us.aliyuncs.com{DASHSCOPE_IMAGE_PATH}", + ), + ( + None, + "https://dashscope-us.aliyuncs.com", + "", + f"https://dashscope-us.aliyuncs.com{DASHSCOPE_IMAGE_PATH}", + ), + ( + "https://dashscope-intl.aliyuncs.com", + "https://gateway.example.com/dashscope/images", + "", + f"https://dashscope-intl.aliyuncs.com{DASHSCOPE_IMAGE_PATH}", + ), + ], +) +def test_image_generation_resolves_from_the_configured_base_not_the_chat_default( + monkeypatch: pytest.MonkeyPatch, + api_base: str | None, + chat_base_env: str, + image_base_env: str, + expected_url: str, +): + monkeypatch.setenv("DASHSCOPE_API_BASE", chat_base_env) + monkeypatch.setenv("DASHSCOPE_API_BASE_IMAGE", image_base_env) + image_url: Final = "https://dashscope-result.oss.aliyuncs.com/t.png" + + with respx.mock(assert_all_called=True) as upstream: + upstream.post(expected_url).respond( + 200, json={"output": {"choices": [{"message": {"content": [{"image": image_url}]}}]}} + ) + response: Final = litellm.image_generation( + model="dashscope/qwen-image-3.0", prompt="a puppy", api_key="sk-test-key", api_base=api_base + ) + + assert response.data[0].url == image_url + + def _transform_response(payload: object) -> ImageResponse: return DashScopeImageGenerationConfig().transform_image_generation_response( model="qwen-image-2.0",