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 01/10] 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 {} From aa91c7a317d2038e26369134efd1c06e700e6bca Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:59:37 +0800 Subject: [PATCH 02/10] 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 --- .../image_generation/transformation.py | 4 +- .../llms/dashscope/messages/transformation.py | 37 +++++++---- .../test_dashscope_messages_transformation.py | 61 +++++++++++++++++++ 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index cfd6b78916e..d1483254fe0 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -122,9 +122,7 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): return f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}" image_api_base: Final = ( - None - if api_base and api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH) - else api_base + 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/transformation.py b/litellm/llms/dashscope/messages/transformation.py index d46fe06f1f7..10fdd539305 100644 --- a/litellm/llms/dashscope/messages/transformation.py +++ b/litellm/llms/dashscope/messages/transformation.py @@ -2,9 +2,10 @@ DashScope Anthropic-compatible Messages transformation config. """ -from typing import Any # noqa: TID251 # override below must mirror the legacy base signature +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.pass_through.messages.transformation import ( AnthropicMessagesConfig, @@ -21,6 +22,8 @@ class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): ``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" @@ -36,23 +39,35 @@ class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): def get_api_base(api_base: str | None = None) -> str: return resolve_dashscope_anthropic_api_base(api_base) - def validate_anthropic_messages_environment( + def validate_anthropic_messages_environment( # pyright: ignore[reportUnknownParameterType] # return type mirrors the base signature self, - headers: dict, # mutable-ok: mirrors the legacy base override signature + headers: dict[str, str], # 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 + 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 | None]: # mutable-ok: mirrors the legacy base override signature - return super().validate_anthropic_messages_environment( + ) -> tuple[dict, str | None]: # mutable-ok: mirrors the base signature # pyright: ignore[reportMissingTypeArgument] # 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] # base signature uses an unparameterized dict 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_key=resolved_api_key, api_base=api_base, ) @@ -61,8 +76,8 @@ class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): 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 + 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/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py index 3151d9f1521..5c3e6ec72dc 100644 --- a/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py @@ -5,6 +5,7 @@ Unit tests for DashScope's Anthropic-compatible Messages endpoint. import pytest import litellm +from litellm.exceptions import AuthenticationError from litellm.llms.dashscope.messages.transformation import ( DashScopeAnthropicMessagesConfig, ) @@ -65,6 +66,66 @@ def test_get_complete_url(api_base: str | None, expected: str): assert url == expected +def test_missing_dashscope_key_does_not_fall_back_to_anthropic(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + 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 = {"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_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 = " ".join(resolved_headers.values()) + assert "sk-dashscope" in header_values + assert "sk-ant-secret" not in header_values + + def test_dashscope_resolves_to_the_native_messages_config(): config = ProviderConfigManager.get_provider_anthropic_messages_config( model="qwen3.7-plus", From 35aafeca64e3f0c2305bf3e49081ed010aa3d551 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:23:58 +0800 Subject: [PATCH 03/10] style(dashscope): keep the messages config within the format gate --- litellm/llms/dashscope/messages/transformation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/llms/dashscope/messages/transformation.py b/litellm/llms/dashscope/messages/transformation.py index 10fdd539305..b96b7c0d41b 100644 --- a/litellm/llms/dashscope/messages/transformation.py +++ b/litellm/llms/dashscope/messages/transformation.py @@ -39,7 +39,7 @@ class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): def get_api_base(api_base: str | None = None) -> str: return resolve_dashscope_anthropic_api_base(api_base) - def validate_anthropic_messages_environment( # pyright: ignore[reportUnknownParameterType] # return type mirrors the base signature + def validate_anthropic_messages_environment( self, headers: dict[str, str], # mutable-ok: mirrors the legacy base override signature model: str, @@ -48,7 +48,7 @@ class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): 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 | None]: # mutable-ok: mirrors the base signature # pyright: ignore[reportMissingTypeArgument] # mirrors the base signature + ) -> 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} @@ -61,7 +61,7 @@ class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): llm_provider="dashscope", model=model, ) - return super().validate_anthropic_messages_environment( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # base signature uses an unparameterized dict + return super().validate_anthropic_messages_environment( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportReturnType] # base signature uses an unparameterized dict headers=headers, model=model, messages=messages, From a005ff9b2170138388a08eb88938ccfe3b290d67 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:10:32 +0800 Subject: [PATCH 04/10] test(dashscope): cover caller Authorization on messages --- .../test_dashscope_messages_transformation.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py index 5c3e6ec72dc..24d79c6a3e3 100644 --- a/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py @@ -107,6 +107,24 @@ def test_caller_auth_header_is_honored_without_a_dashscope_key(monkeypatch: pyte 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 = {"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") From 22a8358a4388ffb8041bef1fed846da3335f8bd0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:33:33 -0700 Subject: [PATCH 05/10] fix(dashscope): keep the chat bridge for protocol-named bases and the image default for unconfigured calls --- litellm/images/main.py | 12 +++- litellm/llms/dashscope/common_utils.py | 32 +++++++++- .../image_generation/transformation.py | 8 +-- .../llms/dashscope/messages/transformation.py | 13 ++++- .../test_websearch_short_circuit.py | 1 + .../dashscope/test_dashscope_common_utils.py | 15 +++++ .../test_dashscope_messages_transformation.py | 58 +++++++++++++++++++ tests/unit/test_dashscope_image_generation.py | 54 +++++++++++++++-- 8 files changed, 176 insertions(+), 17 deletions(-) diff --git a/litellm/images/main.py b/litellm/images/main.py index 9c88e737a40..8e61238338d 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) @@ -465,7 +471,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/common_utils.py b/litellm/llms/dashscope/common_utils.py index 11e2c99829f..3e3dd5e3a9a 100644 --- a/litellm/llms/dashscope/common_utils.py +++ b/litellm/llms/dashscope/common_utils.py @@ -120,10 +120,36 @@ def resolve_dashscope_anthropic_api_base( return candidate or default -def resolve_dashscope_family_rerank_api_base(api_base: str | None, env_var: str, default_rerank_base: str) -> str: +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) + 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: + return None + return f"{parsed.scheme}://{parsed.netloc}" + + +def _rerank_origin(api_base: str | None) -> str | 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}" + return endpoints.origin if endpoints is not None else _chat_shaped_aliyun_origin(api_base) + + +def resolve_dashscope_family_rerank_api_base(api_base: str | None, env_var: str, default_rerank_base: str) -> str: + origin: Final = _rerank_origin(api_base) + if origin is not None: + return get_secret_str(env_var) or f"{origin}{DASHSCOPE_RERANK_PATH}" if api_base is not None: return api_base return get_secret_str(env_var) or default_rerank_base diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index 3b85e783bd7..47ba12b6592 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -118,14 +118,12 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): litellm_params: dict, stream: bool | None = None, ) -> str: + if api_base and api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH): + return self._resolve_image_api_base(None) endpoints: Final = resolve_dashscope_endpoints(api_base) if endpoints is not None: return f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}" - - image_api_base: Final = ( - 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) + return self._resolve_image_api_base(api_base) def validate_environment( self, diff --git a/litellm/llms/dashscope/messages/transformation.py b/litellm/llms/dashscope/messages/transformation.py index b96b7c0d41b..879d2e546e7 100644 --- a/litellm/llms/dashscope/messages/transformation.py +++ b/litellm/llms/dashscope/messages/transformation.py @@ -7,12 +7,15 @@ 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.pass_through.messages.transformation import ( +from litellm.llms.anthropic.messages.native.transformation import ( AnthropicMessagesConfig, ) from litellm.secret_managers.main import get_secret_str -from ..common_utils import resolve_dashscope_anthropic_api_base +from ..common_utils import ( + dashscope_base_selects_anthropic_messages, + resolve_dashscope_anthropic_api_base, +) class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): @@ -31,6 +34,12 @@ class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): 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 api_key or get_secret_str("DASHSCOPE_API_KEY") or litellm.api_key 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 fdc3771e903..d58523949f4 100644 --- a/tests/unit/integrations/websearch_interception/test_websearch_short_circuit.py +++ b/tests/unit/integrations/websearch_interception/test_websearch_short_circuit.py @@ -29,6 +29,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_common_utils.py b/tests/unit/llms/dashscope/test_dashscope_common_utils.py index 332ae293a99..aba20ce5c5c 100644 --- a/tests/unit/llms/dashscope/test_dashscope_common_utils.py +++ b/tests/unit/llms/dashscope/test_dashscope_common_utils.py @@ -134,3 +134,18 @@ def test_rerank_base_uses_the_configured_default(monkeypatch: pytest.MonkeyPatch 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_messages_transformation.py b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py index 24d79c6a3e3..fa11742a7ea 100644 --- a/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py @@ -2,7 +2,10 @@ Unit tests for DashScope's Anthropic-compatible Messages endpoint. """ +from typing import Final + import pytest +import respx import litellm from litellm.exceptions import AuthenticationError @@ -13,6 +16,26 @@ from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager WORKSPACE = "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( @@ -162,3 +185,38 @@ def test_dashscope_prefix_reaches_the_provider(): 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/test_dashscope_image_generation.py b/tests/unit/test_dashscope_image_generation.py index a206ae001cf..2cbf28aca9e 100644 --- a/tests/unit/test_dashscope_image_generation.py +++ b/tests/unit/test_dashscope_image_generation.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch import httpx import pytest +import respx from pydantic import ValidationError import litellm @@ -67,17 +68,26 @@ class TestDashScopeImageGenerationConfig: url = self.cfg.get_complete_url(custom, None, "qwen-image-2.0", {}, {}) assert url == custom + @pytest.mark.parametrize( + "chat_api_base", + [ + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/", + ], + ) + def test_get_complete_url_ignores_chat_compatible_mode_base( + self, chat_api_base: str + ): + 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/compatible-mode/v1", + "https://dashscope.aliyuncs.com", "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", @@ -464,7 +474,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 == f"{DASHSCOPE_DEFAULT_ORIGIN}{DASHSCOPE_IMAGE_PATH}" + assert called_url == DEFAULT_API_BASE # Verify request body contains DashScope format call_kwargs = call_args[1] if call_args[1] else {} @@ -475,6 +485,38 @@ def test_litellm_image_generation_dashscope_end_to_end(model: str): assert body["parameters"]["size"] == "1024*1024" + +@pytest.mark.parametrize( + ("api_base", "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", + ), + ], +) +def test_image_generation_resolves_from_the_callers_base_not_the_chat_default( + monkeypatch: pytest.MonkeyPatch, api_base: str | None, image_base_env: str, expected_url: str +): + monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False) + monkeypatch.setenv("DASHSCOPE_API_BASE_IMAGE", image_base_env) + image_url = "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 = 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", From be9f9afecb26cf964b2da3f1d3f3ce2be7bc5b0f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:34:39 -0700 Subject: [PATCH 06/10] 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 --- litellm/llms/dashscope/common_utils.py | 6 +++ .../image_generation/transformation.py | 10 +++-- .../llms/dashscope/messages/transformation.py | 8 +++- litellm/llms/dashscope/qwen_ai_platform.py | 9 +++-- litellm/llms/dashscope/qwencloud.py | 9 +++-- .../test_dashscope_chat_transformation.py | 11 +++--- .../dashscope/test_dashscope_common_utils.py | 10 +++-- ...test_dashscope_embedding_transformation.py | 9 +++-- .../test_dashscope_messages_transformation.py | 31 ++++++--------- .../test_dashscope_rerank_transformation.py | 11 +++++- .../test_dashscope_video_transformation.py | 4 +- .../llms/dashscope/test_qwen_brand_aliases.py | 26 +++++++++++++ tests/unit/test_dashscope_image_generation.py | 39 ++++++++++++++----- 13 files changed, 127 insertions(+), 56 deletions(-) diff --git a/litellm/llms/dashscope/common_utils.py b/litellm/llms/dashscope/common_utils.py index 3e3dd5e3a9a..cfcd54bbb80 100644 --- a/litellm/llms/dashscope/common_utils.py +++ b/litellm/llms/dashscope/common_utils.py @@ -146,7 +146,13 @@ def _rerank_origin(api_base: str | None) -> str | None: return endpoints.origin if endpoints is not None else _chat_shaped_aliyun_origin(api_base) +def _is_rerank_url(api_base: str) -> bool: + return urlparse(api_base).path.rstrip("/") == DASHSCOPE_RERANK_PATH + + def resolve_dashscope_family_rerank_api_base(api_base: str | None, env_var: str, default_rerank_base: str) -> str: + if api_base is not None and _is_rerank_url(api_base): + return api_base origin: Final = _rerank_origin(api_base) if origin is not None: return get_secret_str(env_var) or f"{origin}{DASHSCOPE_RERANK_PATH}" diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index 47ba12b6592..8aff266367a 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -109,6 +109,9 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): 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 _configured_chat_api_base(self) -> str | None: + return get_secret_str("DASHSCOPE_API_BASE") + def get_complete_url( self, api_base: str | None, @@ -118,12 +121,13 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): litellm_params: dict, stream: bool | None = None, ) -> str: - if api_base and api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH): + configured_api_base: Final = api_base or self._configured_chat_api_base() + if configured_api_base and configured_api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH): return self._resolve_image_api_base(None) - endpoints: Final = resolve_dashscope_endpoints(api_base) + endpoints: Final = resolve_dashscope_endpoints(configured_api_base) if endpoints is not None: return f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}" - return self._resolve_image_api_base(api_base) + return self._resolve_image_api_base(configured_api_base) def validate_environment( self, diff --git a/litellm/llms/dashscope/messages/transformation.py b/litellm/llms/dashscope/messages/transformation.py index 879d2e546e7..9d0d1db91c8 100644 --- a/litellm/llms/dashscope/messages/transformation.py +++ b/litellm/llms/dashscope/messages/transformation.py @@ -10,7 +10,7 @@ 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 +from litellm.secret_managers.main import get_secret_str, normalize_nonempty_secret_str from ..common_utils import ( dashscope_base_selects_anthropic_messages, @@ -42,7 +42,11 @@ class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig): @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 + 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: diff --git a/litellm/llms/dashscope/qwen_ai_platform.py b/litellm/llms/dashscope/qwen_ai_platform.py index f7cc50cc82a..256e01941c0 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): @@ -66,6 +66,9 @@ class QwenAIPlatformImageGenerationConfig(DashScopeImageGenerationConfig): 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 _configured_chat_api_base(self) -> str | None: + return get_secret_str("QWEN_AI_PLATFORM_API_BASE") + class QwenAIPlatformVideoConfig(DashScopeVideoConfig): def _resolve_api_key(self, api_key: str | None) -> str: diff --git a/litellm/llms/dashscope/qwencloud.py b/litellm/llms/dashscope/qwencloud.py index a12e21b0983..b21a2af057a 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): @@ -66,6 +66,9 @@ class QwenCloudImageGenerationConfig(DashScopeImageGenerationConfig): 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 _configured_chat_api_base(self) -> str | None: + return get_secret_str("QWENCLOUD_API_BASE") + class QwenCloudVideoConfig(DashScopeVideoConfig): def _resolve_api_key(self, api_key: str | None) -> str: diff --git a/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py b/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py index 746f15ceb56..99a8004b0a1 100644 --- a/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_chat_transformation.py @@ -5,20 +5,21 @@ 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 = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" +WORKSPACE: Final = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" def test_get_complete_url_derives_the_compatible_base_from_any_standard_base(): - config = DashScopeChatConfig() + config: Final = DashScopeChatConfig() for api_base in ( WORKSPACE, @@ -26,7 +27,7 @@ def test_get_complete_url_derives_the_compatible_base_from_any_standard_base(): f"{WORKSPACE}/apps/anthropic", f"{WORKSPACE}/api/v1", ): - url = config.get_complete_url( + url: Final = config.get_complete_url( api_base=api_base, api_key="sk-test", model="qwen3.7-plus", @@ -38,7 +39,7 @@ def test_get_complete_url_derives_the_compatible_base_from_any_standard_base(): def test_get_complete_url_keeps_unrecognized_hosts(): - url = DashScopeChatConfig().get_complete_url( + url: Final = DashScopeChatConfig().get_complete_url( api_base="https://gateway.example.com/v1", api_key="sk-test", model="qwen3.7-plus", diff --git a/tests/unit/llms/dashscope/test_dashscope_common_utils.py b/tests/unit/llms/dashscope/test_dashscope_common_utils.py index aba20ce5c5c..ff46c970cc4 100644 --- a/tests/unit/llms/dashscope/test_dashscope_common_utils.py +++ b/tests/unit/llms/dashscope/test_dashscope_common_utils.py @@ -2,6 +2,8 @@ Unit tests for DashScope's protocol-specific base URL derivation. """ +from typing import Final + import pytest from litellm.llms.dashscope.common_utils import ( @@ -12,7 +14,7 @@ from litellm.llms.dashscope.common_utils import ( resolve_dashscope_openai_api_base, ) -WORKSPACE = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" +WORKSPACE: Final = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" @pytest.mark.parametrize( @@ -29,7 +31,7 @@ WORKSPACE = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" ], ) def test_workspace_base_derives_every_protocol(api_base: str): - endpoints = resolve_dashscope_endpoints(api_base) + endpoints: Final = resolve_dashscope_endpoints(api_base) assert endpoints is not None assert endpoints.origin == WORKSPACE @@ -48,7 +50,7 @@ def test_workspace_base_derives_every_protocol(api_base: str): ) @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}") + endpoints: Final = resolve_dashscope_endpoints(f"{origin}{path}") assert endpoints is not None assert endpoints.openai == f"{origin}/compatible-mode/v1" @@ -119,7 +121,7 @@ def test_rerank_base_derives_from_the_domain(monkeypatch: pytest.MonkeyPatch): def test_rerank_base_keeps_unrecognized_hosts(): - gateway = "https://gateway.example.com/v1" + gateway: Final = "https://gateway.example.com/v1" assert ( resolve_dashscope_family_rerank_api_base(gateway, "DASHSCOPE_API_BASE_RERANK", "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 5a161f2e088..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,11 +17,11 @@ from litellm.llms.dashscope.embed.transformation import ( ) from litellm.types.utils import EmbeddingResponse -WORKSPACE = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" +WORKSPACE: Final = "https://llm-abc123.cn-beijing.maas.aliyuncs.com" def test_get_complete_url_derives_the_compatible_base_from_any_standard_base(): - config = DashScopeEmbeddingConfig() + config: Final = DashScopeEmbeddingConfig() for api_base in ( WORKSPACE, @@ -28,7 +29,7 @@ def test_get_complete_url_derives_the_compatible_base_from_any_standard_base(): f"{WORKSPACE}/apps/anthropic", f"{WORKSPACE}/api/v1", ): - url = config.get_complete_url( + url: Final = config.get_complete_url( api_base=api_base, api_key="sk-test", model="text-embedding-v4", @@ -40,7 +41,7 @@ def test_get_complete_url_derives_the_compatible_base_from_any_standard_base(): def test_get_complete_url_keeps_unrecognized_hosts(): - url = DashScopeEmbeddingConfig().get_complete_url( + url: Final = DashScopeEmbeddingConfig().get_complete_url( api_base="https://gateway.example.com/v1", api_key="sk-test", model="text-embedding-v4", diff --git a/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py index fa11742a7ea..decc12afd87 100644 --- a/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_messages_transformation.py @@ -12,10 +12,8 @@ from litellm.exceptions import AuthenticationError 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" +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 = { @@ -76,9 +74,9 @@ MESSAGE: Final = { ], ) def test_get_complete_url(api_base: str | None, expected: str): - config = DashScopeAnthropicMessagesConfig() + config: Final = DashScopeAnthropicMessagesConfig() - url = config.get_complete_url( + url: Final = config.get_complete_url( api_base=api_base, api_key="sk-test", model="qwen3.7-plus", @@ -89,8 +87,13 @@ def test_get_complete_url(api_base: str | None, expected: str): assert url == expected -def test_missing_dashscope_key_does_not_fall_back_to_anthropic(monkeypatch: pytest.MonkeyPatch): +@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) @@ -115,7 +118,7 @@ def test_missing_dashscope_key_does_not_fall_back_to_anthropic(monkeypatch: pyte 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 = {"x-api-key": "caller-key"} + headers: Final = {"x-api-key": "caller-key"} resolved_headers, _ = DashScopeAnthropicMessagesConfig().validate_anthropic_messages_environment( headers=headers, @@ -133,7 +136,7 @@ def test_caller_auth_header_is_honored_without_a_dashscope_key(monkeypatch: pyte 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 = {"Authorization": "Bearer caller-token"} + headers: Final = {"Authorization": "Bearer caller-token"} resolved_headers, _ = DashScopeAnthropicMessagesConfig().validate_anthropic_messages_environment( headers=headers, @@ -162,21 +165,11 @@ def test_dashscope_key_wins_over_an_anthropic_environment_key(monkeypatch: pytes api_base=WORKSPACE, ) - header_values = " ".join(resolved_headers.values()) + header_values: Final = " ".join(resolved_headers.values()) assert "sk-dashscope" in header_values assert "sk-ant-secret" not in header_values -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", diff --git a/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py b/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py index 8bac3f69ebf..bdec779558f 100644 --- a/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py @@ -3,6 +3,7 @@ Unit tests for DashScope rerank transformation. """ import json +from typing import Final from unittest.mock import MagicMock import httpx @@ -27,12 +28,20 @@ class TestDashScopeRerankURL: assert url == DEFAULT_RERANK_URL def test_domain_root_remaps_to_rerank_route(self): - url = self.config.get_complete_url( + 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", diff --git a/tests/unit/llms/dashscope/test_dashscope_video_transformation.py b/tests/unit/llms/dashscope/test_dashscope_video_transformation.py index 04ed634b379..c1b4157c8ab 100644 --- a/tests/unit/llms/dashscope/test_dashscope_video_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_video_transformation.py @@ -1120,14 +1120,14 @@ 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" + 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 = DashScopeVideoConfig().get_complete_url( + url: Final = DashScopeVideoConfig().get_complete_url( model="wan3.0-video", api_base=api_base, litellm_params={} ) diff --git a/tests/unit/llms/dashscope/test_qwen_brand_aliases.py b/tests/unit/llms/dashscope/test_qwen_brand_aliases.py index 69ae66d3818..a5167ec5870 100644 --- a/tests/unit/llms/dashscope/test_qwen_brand_aliases.py +++ b/tests/unit/llms/dashscope/test_qwen_brand_aliases.py @@ -2,6 +2,7 @@ import json import math import re from pathlib import Path +from typing import Final import pytest @@ -211,6 +212,18 @@ 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, config_key, route): + 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 +262,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, brand): + 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 2cbf28aca9e..5f99a11519f 100644 --- a/tests/unit/test_dashscope_image_generation.py +++ b/tests/unit/test_dashscope_image_generation.py @@ -5,6 +5,7 @@ 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 @@ -101,7 +102,7 @@ class TestDashScopeImageGenerationConfig: def test_get_complete_url_derives_from_the_domain( self, api_base: str, expected: str ): - url = self.cfg.get_complete_url(api_base, None, "qwen-image-3.0", {}, {}) + url: Final = self.cfg.get_complete_url(api_base, None, "qwen-image-3.0", {}, {}) assert url == expected def test_validate_environment_sets_auth_header(self): @@ -487,30 +488,48 @@ def test_litellm_image_generation_dashscope_end_to_end(model: str): @pytest.mark.parametrize( - ("api_base", "image_base_env", "expected_url"), + ("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"), + (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", + "", + 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_callers_base_not_the_chat_default( - monkeypatch: pytest.MonkeyPatch, api_base: str | None, image_base_env: str, expected_url: str +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.delenv("DASHSCOPE_API_BASE", raising=False) + monkeypatch.setenv("DASHSCOPE_API_BASE", chat_base_env) monkeypatch.setenv("DASHSCOPE_API_BASE_IMAGE", image_base_env) - image_url = "https://dashscope-result.oss.aliyuncs.com/t.png" + 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 = litellm.image_generation( + response: Final = litellm.image_generation( model="dashscope/qwen-image-3.0", prompt="a puppy", api_key="sk-test-key", api_base=api_base ) From 5e83b0a3639c91d8a2ee1cdc8ad693934d237f3f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:32:35 -0700 Subject: [PATCH 07/10] fix(dashscope): keep an explicit compatible-api rerank base as given --- litellm/llms/dashscope/common_utils.py | 13 ++++--- .../test_dashscope_rerank_transformation.py | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/litellm/llms/dashscope/common_utils.py b/litellm/llms/dashscope/common_utils.py index cfcd54bbb80..c0865dfe4c6 100644 --- a/litellm/llms/dashscope/common_utils.py +++ b/litellm/llms/dashscope/common_utils.py @@ -96,12 +96,19 @@ def resolve_dashscope_endpoints(api_base: str | None) -> DashScopeEndpoints | No ) +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_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_compatible_api_base(candidate): + return candidate endpoints: Final = resolve_dashscope_endpoints(candidate) if endpoints is not None: return endpoints.openai @@ -146,12 +153,8 @@ def _rerank_origin(api_base: str | None) -> str | None: return endpoints.origin if endpoints is not None else _chat_shaped_aliyun_origin(api_base) -def _is_rerank_url(api_base: str) -> bool: - return urlparse(api_base).path.rstrip("/") == DASHSCOPE_RERANK_PATH - - def resolve_dashscope_family_rerank_api_base(api_base: str | None, env_var: str, default_rerank_base: str) -> str: - if api_base is not None and _is_rerank_url(api_base): + if api_base is not None and _is_compatible_api_base(api_base): return api_base origin: Final = _rerank_origin(api_base) if origin is not None: diff --git a/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py b/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py index bdec779558f..f7d4b700242 100644 --- a/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py @@ -8,8 +8,10 @@ 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 ( @@ -450,3 +452,35 @@ 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", + ], +) +def test_rerank_sends_an_explicit_compatible_api_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}] From abb6b5f8b3191db47171614dff2ef7a1f32bc7ea Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:21:01 -0700 Subject: [PATCH 08/10] fix(dashscope): derive chat from compatible-api bases, keep the caller's rerank base, and let explicit bases beat per-endpoint env vars --- litellm/llms/dashscope/common_utils.py | 28 ++++++++----------- .../image_generation/transformation.py | 14 ++++++---- litellm/llms/dashscope/qwen_ai_platform.py | 7 +++-- litellm/llms/dashscope/qwencloud.py | 7 +++-- litellm/rerank_api/main.py | 12 +++++++- .../dashscope/test_dashscope_common_utils.py | 3 ++ .../test_dashscope_rerank_transformation.py | 4 ++- tests/unit/test_dashscope_image_generation.py | 7 +++++ 8 files changed, 54 insertions(+), 28 deletions(-) diff --git a/litellm/llms/dashscope/common_utils.py b/litellm/llms/dashscope/common_utils.py index c0865dfe4c6..675d565df64 100644 --- a/litellm/llms/dashscope/common_utils.py +++ b/litellm/llms/dashscope/common_utils.py @@ -96,19 +96,12 @@ def resolve_dashscope_endpoints(api_base: str | None) -> DashScopeEndpoints | No ) -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_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_compatible_api_base(candidate): - return candidate endpoints: Final = resolve_dashscope_endpoints(candidate) if endpoints is not None: return endpoints.openai @@ -148,20 +141,21 @@ def _chat_shaped_aliyun_origin(api_base: str | None) -> str | None: return f"{parsed.scheme}://{parsed.netloc}" -def _rerank_origin(api_base: str | None) -> str | None: - endpoints: Final = resolve_dashscope_endpoints(api_base) - return endpoints.origin if endpoints is not None else _chat_shaped_aliyun_origin(api_base) +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: - if api_base is not None and _is_compatible_api_base(api_base): + 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 - origin: Final = _rerank_origin(api_base) - if origin is not None: - return get_secret_str(env_var) or f"{origin}{DASHSCOPE_RERANK_PATH}" - if api_base is not None: - return api_base - return get_secret_str(env_var) 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/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index 8aff266367a..f5d4da5996e 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -106,8 +106,11 @@ 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") @@ -121,13 +124,14 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): litellm_params: dict, stream: bool | None = None, ) -> str: + image_env_api_base: Final = self._image_api_base_env() configured_api_base: Final = api_base or self._configured_chat_api_base() if configured_api_base and configured_api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH): - return self._resolve_image_api_base(None) + return image_env_api_base or self._default_image_api_base() endpoints: Final = resolve_dashscope_endpoints(configured_api_base) if endpoints is not None: - return f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}" - return self._resolve_image_api_base(configured_api_base) + return (None if api_base else image_env_api_base) or f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}" + return configured_api_base or image_env_api_base or self._default_image_api_base() def validate_environment( self, diff --git a/litellm/llms/dashscope/qwen_ai_platform.py b/litellm/llms/dashscope/qwen_ai_platform.py index 256e01941c0..db01c8a0a15 100644 --- a/litellm/llms/dashscope/qwen_ai_platform.py +++ b/litellm/llms/dashscope/qwen_ai_platform.py @@ -63,8 +63,11 @@ 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") diff --git a/litellm/llms/dashscope/qwencloud.py b/litellm/llms/dashscope/qwencloud.py index b21a2af057a..6ae42c62e42 100644 --- a/litellm/llms/dashscope/qwencloud.py +++ b/litellm/llms/dashscope/qwencloud.py @@ -63,8 +63,11 @@ 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") 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/tests/unit/llms/dashscope/test_dashscope_common_utils.py b/tests/unit/llms/dashscope/test_dashscope_common_utils.py index ff46c970cc4..abb9b1c9329 100644 --- a/tests/unit/llms/dashscope/test_dashscope_common_utils.py +++ b/tests/unit/llms/dashscope/test_dashscope_common_utils.py @@ -81,6 +81,9 @@ def test_openai_base_derives_from_the_domain(monkeypatch: pytest.MonkeyPatch): 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" + ) def test_openai_base_keeps_unrecognized_hosts(monkeypatch: pytest.MonkeyPatch): diff --git a/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py b/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py index f7d4b700242..f0dbdb74b68 100644 --- a/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_rerank_transformation.py @@ -467,9 +467,11 @@ def test_transform_rerank_response_shape_errors_do_not_echo_the_payload(): [ "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_compatible_api_base_over_the_rerank_env( +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") diff --git a/tests/unit/test_dashscope_image_generation.py b/tests/unit/test_dashscope_image_generation.py index 5f99a11519f..6d3905c6574 100644 --- a/tests/unit/test_dashscope_image_generation.py +++ b/tests/unit/test_dashscope_image_generation.py @@ -500,6 +500,13 @@ def test_litellm_image_generation_dashscope_end_to_end(model: str): "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"), + ( + "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", From 2a48c69e3ec129c2d8ec4ded22068409d86a231a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:21:45 -0700 Subject: [PATCH 09/10] fix(dashscope): keep a full DashScope image endpoint URL as given and type the brand tests --- .../image_generation/transformation.py | 8 ++++++ .../llms/dashscope/test_qwen_brand_aliases.py | 26 ++++++++++++++++--- tests/unit/test_dashscope_image_generation.py | 18 +++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index f5d4da5996e..79fe13ce179 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -25,6 +25,7 @@ Response format: from collections.abc import Iterable, Mapping from typing import TYPE_CHECKING, Any, Final +from urllib.parse import urlparse import httpx from pydantic import ConfigDict, TypeAdapter @@ -42,6 +43,7 @@ from litellm.types.utils import ImageObject, ImageResponse from ..common_utils import ( DASHSCOPE_CHAT_COMPATIBLE_PATH, DASHSCOPE_IMAGE_PATH, + DASHSCOPE_NATIVE_V1_PATH, resolve_dashscope_endpoints, ) @@ -70,6 +72,10 @@ OPENAI_TO_DASHSCOPE_SIZE: Final[dict] = { } +def _is_native_endpoint_url(api_base: str) -> bool: + return urlparse(api_base).path.rstrip("/").startswith(f"{DASHSCOPE_NATIVE_V1_PATH}/") + + class DashScopeImageGenerationConfig(BaseImageGenerationConfig): """ Configuration for DashScope image generation (qwen-image-2.0, qwen-image-2.0-pro, @@ -128,6 +134,8 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): configured_api_base: Final = api_base or self._configured_chat_api_base() if configured_api_base and configured_api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH): return image_env_api_base or self._default_image_api_base() + if configured_api_base and _is_native_endpoint_url(configured_api_base): + return configured_api_base endpoints: Final = resolve_dashscope_endpoints(configured_api_base) if endpoints is not None: return (None if api_base else image_env_api_base) or f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}" diff --git a/tests/unit/llms/dashscope/test_qwen_brand_aliases.py b/tests/unit/llms/dashscope/test_qwen_brand_aliases.py index a5167ec5870..43ef8f90b39 100644 --- a/tests/unit/llms/dashscope/test_qwen_brand_aliases.py +++ b/tests/unit/llms/dashscope/test_qwen_brand_aliases.py @@ -2,9 +2,10 @@ import json import math import re from pathlib import Path -from typing import Final +from typing import Final, Literal import pytest +from typing_extensions import ReadOnly, TypedDict import litellm from litellm import completion, get_llm_provider @@ -54,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( { @@ -214,7 +232,9 @@ class TestQwenBrandDefaultUrls: @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, config_key, route): + 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", @@ -263,7 +283,7 @@ 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, brand): + 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( diff --git a/tests/unit/test_dashscope_image_generation.py b/tests/unit/test_dashscope_image_generation.py index 6d3905c6574..abd80e9732e 100644 --- a/tests/unit/test_dashscope_image_generation.py +++ b/tests/unit/test_dashscope_image_generation.py @@ -97,6 +97,10 @@ class TestDashScopeImageGenerationConfig: "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( @@ -105,6 +109,20 @@ class TestDashScopeImageGenerationConfig: 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={}, From 8ddb3f7731a36da0db9d6ed03a16f6893da4e37a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:38:27 -0700 Subject: [PATCH 10/10] fix(dashscope): let the image env var beat any shared DashScope base --- .../dashscope/image_generation/transformation.py | 6 ++++-- tests/unit/test_dashscope_image_generation.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index 79fe13ce179..cdb8fef00d7 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -131,6 +131,8 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): stream: bool | None = None, ) -> str: image_env_api_base: Final = self._image_api_base_env() + if not api_base and image_env_api_base: + return image_env_api_base configured_api_base: Final = api_base or self._configured_chat_api_base() if configured_api_base and configured_api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH): return image_env_api_base or self._default_image_api_base() @@ -138,8 +140,8 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): return configured_api_base endpoints: Final = resolve_dashscope_endpoints(configured_api_base) if endpoints is not None: - return (None if api_base else image_env_api_base) or f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}" - return configured_api_base or image_env_api_base or self._default_image_api_base() + return f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}" + return configured_api_base or self._default_image_api_base() def validate_environment( self, diff --git a/tests/unit/test_dashscope_image_generation.py b/tests/unit/test_dashscope_image_generation.py index abd80e9732e..6e128282c6f 100644 --- a/tests/unit/test_dashscope_image_generation.py +++ b/tests/unit/test_dashscope_image_generation.py @@ -519,6 +519,18 @@ def test_litellm_image_generation_dashscope_end_to_end(model: str): ), (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", + "https://images.example.com/generate", + ), + ( + None, + "https://gateway.example.com/dashscope/images", + "https://images.example.com/generate", + "https://images.example.com/generate", + ), ( "https://dashscope-us.aliyuncs.com", "",