feat(dashscope)!: derive protocol bases from one domain (#47812)

* feat(dashscope): derive protocol bases from one domain

Accept the DashScope domain root, the OpenAI-compatible base, the Anthropic
Messages base, or a native /api/v1 base and derive every surface from it
Adds a native Anthropic Messages config for /apps/anthropic so a single
deployment serves chat, messages, embeddings, rerank, image generation, and
video against the same credential

Co-Authored-By: AaronHowell <111272993+AaronHowell@users.noreply.github.com>

* fix(dashscope): keep Anthropic credentials off DashScope messages

Require a DashScope key or a caller auth header before the Messages path
runs, and opt out of workload identity, so the base class cannot fall back
to ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN

Also format the image generation base selection

* style(dashscope): keep the messages config within the format gate

* test(dashscope): cover caller Authorization on messages

* fix(dashscope): keep the chat bridge for protocol-named bases and the image default for unconfigured calls

* fix(dashscope): keep configured image and rerank bases, normalize blank keys, derive brand chat bases

- image calls fall back to the provider's own chat base env (DASHSCOPE_API_BASE, QWENCLOUD_API_BASE, QWEN_AI_PLATFORM_API_BASE) before the image default
- an explicit /compatible-api/v1/reranks URL wins over DASHSCOPE_API_BASE_RERANK again
- a blank DashScope key counts as unset on /v1/messages, so it never falls back to ANTHROPIC_API_KEY
- QwenCloud and Qianwen AI Platform chat and embeddings derive their compatible-mode base from a domain root
- tests annotate new variables with Final and drop the config-class assertion

* fix(dashscope): keep an explicit compatible-api rerank base as given

* fix(dashscope): derive chat from compatible-api bases, keep the caller's rerank base, and let explicit bases beat per-endpoint env vars

* fix(dashscope): keep a full DashScope image endpoint URL as given and type the brand tests

* fix(dashscope): let the image env var beat any shared DashScope base

* fix(dashscope): keep a full DASHSCOPE_API_BASE image URL ahead of the image env var

* fix(dashscope): keep a native DashScope endpoint URL as given for chat and the api base header

---------

Co-authored-by: AaronHowell <111272993+AaronHowell@users.noreply.github.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Rick 2026-10-30 00:08:18 +08:00 • committed by GitHub
parent 06cdee91da
commit c98641fd5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1015 additions and 37 deletions

View file

@ -68,6 +68,11 @@ from litellm.utils import (
)
_PROVIDER_ROUTING_HINTS: Final = TypeAdapter(ProviderRoutingHints)
_PROVIDERS_RESOLVING_THEIR_OWN_IMAGE_BASE: Final = (
LlmProviders.DASHSCOPE,
LlmProviders.QWENCLOUD,
LlmProviders.QWEN_AI_PLATFORM,
)
# Cache for ImageEditRequestUtils to avoid repeated __getattr__ calls
_ImageEditRequestUtils_cache: type["ImageEditRequestUtils"] | None = None
@ -271,6 +276,7 @@ def image_generation(
"""
try:
args: Final = locals()
requested_api_base: Final = api_base
aimg_generation: Final = kwargs.get("aimg_generation", False)
litellm_call_id: Final = kwargs.get("litellm_call_id", None)
logger_fn: Final = kwargs.get("logger_fn", None)
@ -468,7 +474,11 @@ def image_generation(
raise ValueError(f"image generation config is not supported for {custom_llm_provider}")
# Resolve api_base from litellm.api_base if not explicitly provided
_api_base: Final = api_base or litellm.api_base
_api_base: Final = (
requested_api_base
if custom_llm_provider in _PROVIDERS_RESOLVING_THEIR_OWN_IMAGE_BASE
else api_base or litellm.api_base
)
litellm_params_dict["api_base"] = _api_base
return llm_http_handler.image_generation_handler(

View file

@ -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,

View file

@ -2,6 +2,7 @@
Common utilities for the DashScope LLM provider.
"""
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from urllib.parse import urlparse
@ -19,10 +20,123 @@ if TYPE_CHECKING:
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
DASHSCOPE_CHAT_COMPATIBLE_PATH: Final = "/compatible-mode/v1"
DASHSCOPE_RERANK_PATH: Final = "/compatible-api/v1/reranks"
DASHSCOPE_ANTHROPIC_PATH: Final = "/apps/anthropic"
DASHSCOPE_NATIVE_V1_PATH: Final = "/api/v1"
DASHSCOPE_COMPATIBLE_API_PATH: Final = "/compatible-api/v1"
DASHSCOPE_RERANK_PATH: Final = f"{DASHSCOPE_COMPATIBLE_API_PATH}/reranks"
DASHSCOPE_IMAGE_PATH: Final = "/api/v1/services/aigc/multimodal-generation/generation"
DASHSCOPE_DEFAULT_ORIGIN: Final = "https://dashscope.aliyuncs.com"
_STANDARD_DASHSCOPE_HOSTS: Final = frozenset(
{
"dashscope.aliyuncs.com",
"dashscope-intl.aliyuncs.com",
"dashscope-us.aliyuncs.com",
}
)
_WORKSPACE_HOST_SUFFIX: Final = ".maas.aliyuncs.com"
_EXCLUDED_WORKSPACE_PREFIXES: Final = ("trial.", "token-plan.")
_PROTOCOL_PATH_PREFIXES: Final = (
DASHSCOPE_CHAT_COMPATIBLE_PATH,
DASHSCOPE_ANTHROPIC_PATH,
DASHSCOPE_NATIVE_V1_PATH,
DASHSCOPE_COMPATIBLE_API_PATH,
)
def _rerank_base_for_chat_shaped_base(api_base: str | None) -> str | None:
@dataclass(frozen=True, slots=True)
class DashScopeEndpoints:
origin: str
openai: str
anthropic: str
native: str
def _is_dashscope_host(host: str) -> bool:
if host in _STANDARD_DASHSCOPE_HOSTS:
return True
return host.endswith(_WORKSPACE_HOST_SUFFIX) and not host.startswith(_EXCLUDED_WORKSPACE_PREFIXES)
def _dashscope_origin(api_base: str | None) -> str | None:
if not api_base:
return None
parsed: Final = urlparse(api_base)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
if parsed.query or parsed.fragment:
return None
host: Final = (parsed.hostname or "").lower()
if not _is_dashscope_host(host):
return None
path: Final = parsed.path.rstrip("/")
if path and not any(path == prefix or path.startswith(f"{prefix}/") for prefix in _PROTOCOL_PATH_PREFIXES):
return None
return f"{parsed.scheme}://{parsed.netloc}"
def resolve_dashscope_endpoints(api_base: str | None) -> DashScopeEndpoints | None:
"""
Derive DashScope's protocol-specific bases from any standard DashScope base.
Accepts the domain root, an OpenAI-compatible base, an Anthropic Messages
base, or a native `/api/v1` base for `dashscope.aliyuncs.com`,
`dashscope-intl.aliyuncs.com`, `dashscope-us.aliyuncs.com`, and
`{workspace}.{region}.maas.aliyuncs.com`. Returns None for any other host
or path so callers keep the caller-supplied base untouched.
"""
origin: Final = _dashscope_origin(api_base)
if origin is None:
return None
return DashScopeEndpoints(
origin=origin,
openai=f"{origin}{DASHSCOPE_CHAT_COMPATIBLE_PATH}",
anthropic=f"{origin}{DASHSCOPE_ANTHROPIC_PATH}",
native=f"{origin}{DASHSCOPE_NATIVE_V1_PATH}",
)
def is_dashscope_native_endpoint_url(api_base: str) -> bool:
return urlparse(api_base).path.rstrip("/").startswith(f"{DASHSCOPE_NATIVE_V1_PATH}/")
def resolve_dashscope_openai_api_base(
api_base: str | None,
env_var: str = "DASHSCOPE_API_BASE",
default: str = f"{DASHSCOPE_DEFAULT_ORIGIN}{DASHSCOPE_CHAT_COMPATIBLE_PATH}",
) -> str:
candidate: Final = api_base or get_secret_str(env_var)
if candidate and is_dashscope_native_endpoint_url(candidate):
return candidate
endpoints: Final = resolve_dashscope_endpoints(candidate)
if endpoints is not None:
return endpoints.openai
return candidate or default
def resolve_dashscope_anthropic_api_base(
api_base: str | None,
env_var: str = "DASHSCOPE_API_BASE",
default: str = f"{DASHSCOPE_DEFAULT_ORIGIN}{DASHSCOPE_ANTHROPIC_PATH}",
) -> str:
candidate: Final = api_base or get_secret_str(env_var)
endpoints: Final = resolve_dashscope_endpoints(candidate)
if endpoints is not None:
return endpoints.anthropic
return candidate or default
def dashscope_base_selects_anthropic_messages(api_base: str | None) -> bool:
candidate: Final = api_base or get_secret_str("DASHSCOPE_API_BASE")
if not candidate:
return False
path: Final = urlparse(candidate).path.rstrip("/")
if path.endswith(DASHSCOPE_ANTHROPIC_PATH) or f"{DASHSCOPE_ANTHROPIC_PATH}/" in path:
return True
return not path and resolve_dashscope_endpoints(candidate) is not None
def _chat_shaped_aliyun_origin(api_base: str | None) -> str | None:
if api_base is None:
return None
parsed: Final = urlparse(api_base)
@ -30,14 +144,24 @@ def _rerank_base_for_chat_shaped_base(api_base: str | None) -> str | None:
on_aliyun_host: Final = host == "aliyuncs.com" or host.endswith(".aliyuncs.com")
if not on_aliyun_host or parsed.path.rstrip("/") != DASHSCOPE_CHAT_COMPATIBLE_PATH:
return None
return f"{parsed.scheme}://{parsed.netloc}{DASHSCOPE_RERANK_PATH}"
return f"{parsed.scheme}://{parsed.netloc}"
def _is_compatible_api_base(api_base: str) -> bool:
path: Final = urlparse(api_base).path.rstrip("/")
return path == DASHSCOPE_COMPATIBLE_API_PATH or path.startswith(f"{DASHSCOPE_COMPATIBLE_API_PATH}/")
def resolve_dashscope_family_rerank_api_base(api_base: str | None, env_var: str, default_rerank_base: str) -> str:
remapped: Final = _rerank_base_for_chat_shaped_base(api_base)
if api_base is not None and remapped is None:
if api_base is None:
return get_secret_str(env_var) or default_rerank_base
if _is_compatible_api_base(api_base):
return api_base
return get_secret_str(env_var) or remapped or default_rerank_base
chat_origin: Final = _chat_shaped_aliyun_origin(api_base)
if chat_origin is not None:
return get_secret_str(env_var) or f"{chat_origin}{DASHSCOPE_RERANK_PATH}"
endpoints: Final = resolve_dashscope_endpoints(api_base)
return f"{endpoints.origin}{DASHSCOPE_RERANK_PATH}" if endpoints is not None else api_base
def get_dashscope_family_embedding_config(custom_llm_provider: str) -> "BaseEmbeddingConfig":

View file

@ -25,7 +25,7 @@ from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues
from litellm.types.utils import EmbeddingResponse, Usage
from ..common_utils import DashScopeError
from ..common_utils import DashScopeError, resolve_dashscope_openai_api_base
DEFAULT_API_BASE: Final = "https://dashscope.aliyuncs.com/compatible-mode/v1"
@ -91,7 +91,7 @@ class DashScopeEmbeddingConfig(BaseEmbeddingConfig):
return resolved_api_key
def _resolve_embedding_api_base(self, api_base: str | None) -> str:
return api_base or get_secret_str("DASHSCOPE_API_BASE") or DEFAULT_API_BASE
return resolve_dashscope_openai_api_base(api_base, default=DEFAULT_API_BASE)
def validate_environment(
self,

View file

@ -39,6 +39,13 @@ from litellm.types.llms.openai import (
)
from litellm.types.utils import ImageObject, ImageResponse
from ..common_utils import (
DASHSCOPE_CHAT_COMPATIBLE_PATH,
DASHSCOPE_IMAGE_PATH,
is_dashscope_native_endpoint_url,
resolve_dashscope_endpoints,
)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer
@ -53,8 +60,6 @@ _DECODED_JSON: Final = TypeAdapter(object)
DEFAULT_API_BASE: Final = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
CHAT_COMPATIBLE_MODE_PATH: Final = "/compatible-mode/v1"
# Maps OpenAI size strings (WxH) to DashScope size strings (W*H)
OPENAI_TO_DASHSCOPE_SIZE: Final[dict] = {
"256x256": "256*256",
@ -102,8 +107,14 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
raise ValueError("DASHSCOPE_API_KEY is not set")
return resolved_api_key
def _resolve_image_api_base(self, image_api_base: str | None) -> str:
return image_api_base or get_secret_str("DASHSCOPE_API_BASE_IMAGE") or DEFAULT_API_BASE
def _image_api_base_env(self) -> str | None:
return get_secret_str("DASHSCOPE_API_BASE_IMAGE")
def _default_image_api_base(self) -> str:
return DEFAULT_API_BASE
def _configured_chat_api_base(self) -> str | None:
return get_secret_str("DASHSCOPE_API_BASE")
def get_complete_url(
self,
@ -114,10 +125,18 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
litellm_params: dict,
stream: bool | None = None,
) -> str:
image_api_base: Final = (
api_base if api_base and not api_base.rstrip("/").endswith(CHAT_COMPATIBLE_MODE_PATH) else None
)
return self._resolve_image_api_base(image_api_base)
image_env_api_base: Final = self._image_api_base_env()
configured_api_base: Final = api_base or self._configured_chat_api_base()
if not configured_api_base or configured_api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH):
return image_env_api_base or self._default_image_api_base()
if is_dashscope_native_endpoint_url(configured_api_base):
return configured_api_base
endpoints: Final = resolve_dashscope_endpoints(configured_api_base)
if endpoints is None:
return configured_api_base
if not api_base and image_env_api_base:
return image_env_api_base
return f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}"
def validate_environment(
self,

View file

@ -0,0 +1,7 @@
"""
DashScope Anthropic-compatible Messages Module
"""
from .transformation import DashScopeAnthropicMessagesConfig
__all__ = ["DashScopeAnthropicMessagesConfig"]

View file

@ -0,0 +1,96 @@
"""
DashScope Anthropic-compatible Messages transformation config.
"""
from typing import ClassVar, Final
import litellm
from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.url_utils import ensure_api_route
from litellm.llms.anthropic.messages.native.transformation import (
AnthropicMessagesConfig,
)
from litellm.secret_managers.main import get_secret_str, normalize_nonempty_secret_str
from ..common_utils import (
dashscope_base_selects_anthropic_messages,
resolve_dashscope_anthropic_api_base,
)
class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig):
"""
DashScope exposes an Anthropic-compatible Messages API at
``{origin}/apps/anthropic`` for every DashScope domain, including
``dashscope.aliyuncs.com`` and ``{workspace}.{region}.maas.aliyuncs.com``.
"""
_workload_identity_eligible: ClassVar[bool] = False
@property
def custom_llm_provider(self) -> str | None:
return "dashscope"
def should_strip_billing_metadata(self) -> bool:
return True
def handles_web_search_natively(self) -> bool:
return False
def serves_api_base(self, model: str, api_base: str | None) -> bool:
return dashscope_base_selects_anthropic_messages(api_base)
@staticmethod
def get_api_key(api_key: str | None = None) -> str | None:
return (
normalize_nonempty_secret_str(api_key)
or normalize_nonempty_secret_str(get_secret_str("DASHSCOPE_API_KEY"))
or normalize_nonempty_secret_str(litellm.api_key)
)
@staticmethod
def get_api_base(api_base: str | None = None) -> str:
return resolve_dashscope_anthropic_api_base(api_base)
def validate_anthropic_messages_environment(
self,
headers: dict[str, str], # mutable-ok: mirrors the legacy base override signature
model: str,
messages: list[object], # mutable-ok: mirrors the legacy base override signature
optional_params: dict[str, object], # mutable-ok: mirrors the legacy base override signature
litellm_params: dict[str, object], # mutable-ok: mirrors the legacy base override signature
api_key: str | None = None,
api_base: str | None = None,
) -> tuple[dict[str, str], str | None]: # mutable-ok: mirrors the base signature
resolved_api_key: Final = self.get_api_key(api_key=api_key)
if resolved_api_key is None:
present_headers: Final = {name.lower() for name in headers}
if "x-api-key" not in present_headers and "authorization" not in present_headers:
raise AuthenticationError(
message=(
"Missing DashScope API key. Set DASHSCOPE_API_KEY or pass api_key explicitly. "
"Anthropic credentials are never sent to a DashScope endpoint."
),
llm_provider="dashscope",
model=model,
)
return super().validate_anthropic_messages_environment( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportReturnType] # base signature uses an unparameterized dict
headers=headers,
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=resolved_api_key,
api_base=api_base,
)
def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
optional_params: dict[str, object], # mutable-ok: mirrors the base get_complete_url signature
litellm_params: dict[str, object], # mutable-ok: mirrors the base get_complete_url signature
stream: bool | None = None,
) -> str:
return ensure_api_route(self.get_api_base(api_base=api_base), "v1/messages")

View file

@ -3,7 +3,7 @@ from typing import Final
from litellm.secret_managers.main import get_secret_str
from .chat.transformation import DashScopeChatConfig
from .common_utils import resolve_dashscope_family_rerank_api_base
from .common_utils import resolve_dashscope_family_rerank_api_base, resolve_dashscope_openai_api_base
from .embed.transformation import DashScopeEmbeddingConfig
from .image_generation.transformation import DashScopeImageGenerationConfig
from .rerank.transformation import DashScopeRerankConfig
@ -38,7 +38,7 @@ class QwenAIPlatformChatConfig(DashScopeChatConfig):
return self._resolve_chat_api_base(api_base), _resolve_qwen_ai_platform_api_key(api_key)
def _resolve_chat_api_base(self, api_base: str | None) -> str:
return api_base or get_secret_str("QWEN_AI_PLATFORM_API_BASE") or QWEN_AI_PLATFORM_API_BASE
return resolve_dashscope_openai_api_base(api_base, "QWEN_AI_PLATFORM_API_BASE", QWEN_AI_PLATFORM_API_BASE)
class QwenAIPlatformEmbeddingConfig(DashScopeEmbeddingConfig):
@ -46,7 +46,7 @@ class QwenAIPlatformEmbeddingConfig(DashScopeEmbeddingConfig):
return _require_qwen_ai_platform_api_key(api_key)
def _resolve_embedding_api_base(self, api_base: str | None) -> str:
return api_base or get_secret_str("QWEN_AI_PLATFORM_API_BASE") or QWEN_AI_PLATFORM_API_BASE
return resolve_dashscope_openai_api_base(api_base, "QWEN_AI_PLATFORM_API_BASE", QWEN_AI_PLATFORM_API_BASE)
class QwenAIPlatformRerankConfig(DashScopeRerankConfig):
@ -63,8 +63,14 @@ class QwenAIPlatformImageGenerationConfig(DashScopeImageGenerationConfig):
def _resolve_api_key(self, api_key: str | None) -> str:
return _require_qwen_ai_platform_api_key(api_key)
def _resolve_image_api_base(self, image_api_base: str | None) -> str:
return image_api_base or get_secret_str("QWEN_AI_PLATFORM_API_BASE_IMAGE") or QWEN_AI_PLATFORM_IMAGE_API_BASE
def _image_api_base_env(self) -> str | None:
return get_secret_str("QWEN_AI_PLATFORM_API_BASE_IMAGE")
def _default_image_api_base(self) -> str:
return QWEN_AI_PLATFORM_IMAGE_API_BASE
def _configured_chat_api_base(self) -> str | None:
return get_secret_str("QWEN_AI_PLATFORM_API_BASE")
class QwenAIPlatformVideoConfig(DashScopeVideoConfig):

View file

@ -3,7 +3,7 @@ from typing import Final
from litellm.secret_managers.main import get_secret_str
from .chat.transformation import DashScopeChatConfig
from .common_utils import resolve_dashscope_family_rerank_api_base
from .common_utils import resolve_dashscope_family_rerank_api_base, resolve_dashscope_openai_api_base
from .embed.transformation import DashScopeEmbeddingConfig
from .image_generation.transformation import DashScopeImageGenerationConfig
from .rerank.transformation import DashScopeRerankConfig
@ -38,7 +38,7 @@ class QwenCloudChatConfig(DashScopeChatConfig):
return self._resolve_chat_api_base(api_base), _resolve_qwencloud_api_key(api_key)
def _resolve_chat_api_base(self, api_base: str | None) -> str:
return api_base or get_secret_str("QWENCLOUD_API_BASE") or QWENCLOUD_API_BASE
return resolve_dashscope_openai_api_base(api_base, "QWENCLOUD_API_BASE", QWENCLOUD_API_BASE)
class QwenCloudEmbeddingConfig(DashScopeEmbeddingConfig):
@ -46,7 +46,7 @@ class QwenCloudEmbeddingConfig(DashScopeEmbeddingConfig):
return _require_qwencloud_api_key(api_key)
def _resolve_embedding_api_base(self, api_base: str | None) -> str:
return api_base or get_secret_str("QWENCLOUD_API_BASE") or QWENCLOUD_API_BASE
return resolve_dashscope_openai_api_base(api_base, "QWENCLOUD_API_BASE", QWENCLOUD_API_BASE)
class QwenCloudRerankConfig(DashScopeRerankConfig):
@ -63,8 +63,14 @@ class QwenCloudImageGenerationConfig(DashScopeImageGenerationConfig):
def _resolve_api_key(self, api_key: str | None) -> str:
return _require_qwencloud_api_key(api_key)
def _resolve_image_api_base(self, image_api_base: str | None) -> str:
return image_api_base or get_secret_str("QWENCLOUD_API_BASE_IMAGE") or QWENCLOUD_IMAGE_API_BASE
def _image_api_base_env(self) -> str | None:
return get_secret_str("QWENCLOUD_API_BASE_IMAGE")
def _default_image_api_base(self) -> str:
return QWENCLOUD_IMAGE_API_BASE
def _configured_chat_api_base(self) -> str | None:
return get_secret_str("QWENCLOUD_API_BASE")
class QwenCloudVideoConfig(DashScopeVideoConfig):

View file

@ -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)]

View file

@ -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,

View file

@ -10048,6 +10048,12 @@ class ProviderConfigManager:
)
return TencentAnthropicMessagesConfig()
elif litellm.LlmProviders.DASHSCOPE == provider:
from litellm.llms.dashscope.messages.transformation import (
DashScopeAnthropicMessagesConfig,
)
return DashScopeAnthropicMessagesConfig()
elif litellm.LlmProviders.GITHUB_COPILOT == provider:
if "claude" in model_lower:
from litellm.llms.github_copilot.messages.transformation import (

View file

@ -32,6 +32,7 @@ class TestTryShortCircuitSearch:
("github_copilot", "github_copilot/claude-sonnet-5.5"),
("databricks", "databricks/databricks-claude-sonnet-5-5"),
("databricks", "databricks/system.ai.claude-sonnet-5-5"),
("dashscope", "dashscope/qwen3.7-plus"),
],
)
async def test_short_circuits_single_web_search_tool(self, provider, model):

View file

@ -5,15 +5,50 @@ These tests validate the DashScopeConfig class which extends OpenAIGPTConfig.
DashScope is an OpenAI-compatible provider with minor customizations.
"""
from typing import Final
import pytest
from litellm.types.llms.openai import AllMessageValues
import pytest
import litellm
from litellm import completion
from litellm.llms.dashscope.chat.transformation import DashScopeChatConfig
WORKSPACE: Final = "https://llm-abc123.cn-beijing.maas.aliyuncs.com"
def test_get_complete_url_derives_the_compatible_base_from_any_standard_base():
config: Final = DashScopeChatConfig()
for api_base in (
WORKSPACE,
f"{WORKSPACE}/compatible-mode/v1",
f"{WORKSPACE}/apps/anthropic",
f"{WORKSPACE}/api/v1",
):
url: Final = config.get_complete_url(
api_base=api_base,
api_key="sk-test",
model="qwen3.7-plus",
optional_params={},
litellm_params={},
)
assert url == f"{WORKSPACE}/compatible-mode/v1/chat/completions"
def test_get_complete_url_keeps_unrecognized_hosts():
url: Final = DashScopeChatConfig().get_complete_url(
api_base="https://gateway.example.com/v1",
api_key="sk-test",
model="qwen3.7-plus",
optional_params={},
litellm_params={},
)
assert url == "https://gateway.example.com/v1/chat/completions"
class TestDashScopeConfig:
"""Test class for DashScope functionality"""

View file

@ -0,0 +1,171 @@
"""
Unit tests for DashScope's protocol-specific base URL derivation.
"""
from typing import Final
import pytest
from litellm.llms.dashscope.common_utils import (
DASHSCOPE_DEFAULT_ORIGIN,
resolve_dashscope_anthropic_api_base,
resolve_dashscope_endpoints,
resolve_dashscope_family_rerank_api_base,
resolve_dashscope_openai_api_base,
)
WORKSPACE: Final = "https://llm-abc123.cn-beijing.maas.aliyuncs.com"
@pytest.mark.parametrize(
"api_base",
[
WORKSPACE,
f"{WORKSPACE}/",
f"{WORKSPACE}/compatible-mode/v1",
f"{WORKSPACE}/compatible-mode/v1/chat/completions",
f"{WORKSPACE}/apps/anthropic",
f"{WORKSPACE}/apps/anthropic/v1/messages",
f"{WORKSPACE}/api/v1",
f"{WORKSPACE}/api/v1/services/aigc/video-generation/video-synthesis",
],
)
def test_workspace_base_derives_every_protocol(api_base: str):
endpoints: Final = resolve_dashscope_endpoints(api_base)
assert endpoints is not None
assert endpoints.origin == WORKSPACE
assert endpoints.openai == f"{WORKSPACE}/compatible-mode/v1"
assert endpoints.anthropic == f"{WORKSPACE}/apps/anthropic"
assert endpoints.native == f"{WORKSPACE}/api/v1"
@pytest.mark.parametrize(
"origin",
[
"https://dashscope.aliyuncs.com",
"https://dashscope-intl.aliyuncs.com",
"https://dashscope-us.aliyuncs.com",
],
)
@pytest.mark.parametrize("path", ["", "/", "/compatible-mode/v1"])
def test_standard_hosts_derive_every_protocol(origin: str, path: str):
endpoints: Final = resolve_dashscope_endpoints(f"{origin}{path}")
assert endpoints is not None
assert endpoints.openai == f"{origin}/compatible-mode/v1"
assert endpoints.anthropic == f"{origin}/apps/anthropic"
assert endpoints.native == f"{origin}/api/v1"
@pytest.mark.parametrize(
"api_base",
[
None,
"",
"not-a-url",
"https://gateway.example.com",
"https://gateway.example.com/compatible-mode/v1",
f"{WORKSPACE}/custom-path",
f"{WORKSPACE}/compatible-mode/v1?api-version=1",
"https://trial.cn-beijing.maas.aliyuncs.com",
"https://token-plan.cn-beijing.maas.aliyuncs.com",
],
)
def test_unrecognized_bases_are_not_rewritten(api_base: str | None):
assert resolve_dashscope_endpoints(api_base) is None
def test_openai_base_derives_from_the_domain(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
assert resolve_dashscope_openai_api_base(WORKSPACE) == f"{WORKSPACE}/compatible-mode/v1"
assert resolve_dashscope_openai_api_base(f"{WORKSPACE}/apps/anthropic") == f"{WORKSPACE}/compatible-mode/v1"
assert (
resolve_dashscope_openai_api_base(f"{WORKSPACE}/compatible-api/v1/reranks") == f"{WORKSPACE}/compatible-mode/v1"
)
@pytest.mark.parametrize(
"endpoint_url",
[
"https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
f"{WORKSPACE}/api/v1/services/aigc/video-generation/video-synthesis",
],
)
def test_openai_base_keeps_a_native_endpoint_url(monkeypatch: pytest.MonkeyPatch, endpoint_url: str):
monkeypatch.setenv("DASHSCOPE_API_BASE", endpoint_url)
assert resolve_dashscope_openai_api_base(None) == endpoint_url
assert resolve_dashscope_openai_api_base(endpoint_url) == endpoint_url
assert resolve_dashscope_openai_api_base(f"{WORKSPACE}/api/v1") == f"{WORKSPACE}/compatible-mode/v1"
def test_openai_base_keeps_unrecognized_hosts(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
assert resolve_dashscope_openai_api_base("https://gateway.example.com/v1") == "https://gateway.example.com/v1"
def test_openai_base_defaults_to_dashscope(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
assert resolve_dashscope_openai_api_base(None) == f"{DASHSCOPE_DEFAULT_ORIGIN}/compatible-mode/v1"
def test_anthropic_base_derives_from_the_domain(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
assert resolve_dashscope_anthropic_api_base(WORKSPACE) == f"{WORKSPACE}/apps/anthropic"
assert resolve_dashscope_anthropic_api_base(f"{WORKSPACE}/compatible-mode/v1") == f"{WORKSPACE}/apps/anthropic"
def test_anthropic_base_keeps_unrecognized_hosts(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
assert (
resolve_dashscope_anthropic_api_base("https://gateway.example.com/anthropic")
== "https://gateway.example.com/anthropic"
)
def test_rerank_base_derives_from_the_domain(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("DASHSCOPE_API_BASE_RERANK", raising=False)
assert (
resolve_dashscope_family_rerank_api_base(WORKSPACE, "DASHSCOPE_API_BASE_RERANK", "https://default/reranks")
== f"{WORKSPACE}/compatible-api/v1/reranks"
)
def test_rerank_base_keeps_unrecognized_hosts():
gateway: Final = "https://gateway.example.com/v1"
assert (
resolve_dashscope_family_rerank_api_base(gateway, "DASHSCOPE_API_BASE_RERANK", "https://default/reranks")
== gateway
)
def test_rerank_base_uses_the_configured_default(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("DASHSCOPE_API_BASE_RERANK", raising=False)
assert (
resolve_dashscope_family_rerank_api_base(None, "DASHSCOPE_API_BASE_RERANK", "https://default/reranks")
== "https://default/reranks"
)
@pytest.mark.parametrize(
"origin",
["https://cn-hongkong.dashscope.aliyuncs.com", "https://token-plan.cn-beijing.maas.aliyuncs.com"],
)
def test_rerank_keeps_remapping_chat_bases_on_other_aliyun_hosts(monkeypatch: pytest.MonkeyPatch, origin: str):
monkeypatch.delenv("DASHSCOPE_API_BASE_RERANK", raising=False)
assert (
resolve_dashscope_family_rerank_api_base(
f"{origin}/compatible-mode/v1", "DASHSCOPE_API_BASE_RERANK", "https://default/reranks"
)
== f"{origin}/compatible-api/v1/reranks"
)

View file

@ -3,6 +3,7 @@ Unit tests for DashScope embedding transformation.
"""
import json
from typing import Final
from unittest.mock import MagicMock
import httpx
@ -16,6 +17,40 @@ from litellm.llms.dashscope.embed.transformation import (
)
from litellm.types.utils import EmbeddingResponse
WORKSPACE: Final = "https://llm-abc123.cn-beijing.maas.aliyuncs.com"
def test_get_complete_url_derives_the_compatible_base_from_any_standard_base():
config: Final = DashScopeEmbeddingConfig()
for api_base in (
WORKSPACE,
f"{WORKSPACE}/compatible-mode/v1",
f"{WORKSPACE}/apps/anthropic",
f"{WORKSPACE}/api/v1",
):
url: Final = config.get_complete_url(
api_base=api_base,
api_key="sk-test",
model="text-embedding-v4",
optional_params={},
litellm_params={},
)
assert url == f"{WORKSPACE}/compatible-mode/v1/embeddings"
def test_get_complete_url_keeps_unrecognized_hosts():
url: Final = DashScopeEmbeddingConfig().get_complete_url(
api_base="https://gateway.example.com/v1",
api_key="sk-test",
model="text-embedding-v4",
optional_params={},
litellm_params={},
)
assert url == "https://gateway.example.com/v1/embeddings"
def test_validate_environment_and_url():
config = DashScopeEmbeddingConfig()

View file

@ -0,0 +1,215 @@
"""
Unit tests for DashScope's Anthropic-compatible Messages endpoint.
"""
from typing import Final
import pytest
import respx
import litellm
from litellm.exceptions import AuthenticationError
from litellm.llms.dashscope.messages.transformation import (
DashScopeAnthropicMessagesConfig,
)
WORKSPACE: Final = "https://llm-abc123.cn-beijing.maas.aliyuncs.com"
INTL: Final = "https://dashscope-intl.aliyuncs.com"
CODING_PLAN: Final = "https://coding-intl.dashscope.aliyuncs.com"
CHAT_COMPLETION: Final = {
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 1792227137,
"model": "qwen3.7-plus",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10},
}
MESSAGE: Final = {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "qwen3.7-plus",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 9, "output_tokens": 1},
}
@pytest.mark.parametrize(
("api_base", "expected"),
[
(
None,
"https://dashscope.aliyuncs.com/apps/anthropic/v1/messages",
),
(
"https://dashscope.aliyuncs.com",
"https://dashscope.aliyuncs.com/apps/anthropic/v1/messages",
),
(
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://dashscope.aliyuncs.com/apps/anthropic/v1/messages",
),
(
"https://dashscope.aliyuncs.com/apps/anthropic",
"https://dashscope.aliyuncs.com/apps/anthropic/v1/messages",
),
(
"https://dashscope.aliyuncs.com/apps/anthropic/v1/messages",
"https://dashscope.aliyuncs.com/apps/anthropic/v1/messages",
),
(
WORKSPACE,
f"{WORKSPACE}/apps/anthropic/v1/messages",
),
(
f"{WORKSPACE}/compatible-mode/v1",
f"{WORKSPACE}/apps/anthropic/v1/messages",
),
(
"https://gateway.example.com/anthropic",
"https://gateway.example.com/anthropic/v1/messages",
),
],
)
def test_get_complete_url(api_base: str | None, expected: str):
config: Final = DashScopeAnthropicMessagesConfig()
url: Final = config.get_complete_url(
api_base=api_base,
api_key="sk-test",
model="qwen3.7-plus",
optional_params={},
litellm_params={},
)
assert url == expected
@pytest.mark.parametrize("dashscope_api_key", [None, " "])
def test_missing_dashscope_key_does_not_fall_back_to_anthropic(
monkeypatch: pytest.MonkeyPatch, dashscope_api_key: str | None
):
monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False)
if dashscope_api_key is not None:
monkeypatch.setenv("DASHSCOPE_API_KEY", dashscope_api_key)
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-secret")
monkeypatch.setattr(litellm, "api_key", None)
headers: dict[str, str] = {}
with pytest.raises(AuthenticationError) as error:
DashScopeAnthropicMessagesConfig().validate_anthropic_messages_environment(
headers=headers,
model="qwen3.7-plus",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
api_base=WORKSPACE,
)
assert "DashScope" in str(error.value)
assert "sk-ant-secret" not in str(error.value)
assert {name.lower() for name in headers}.isdisjoint({"x-api-key", "authorization"})
def test_caller_auth_header_is_honored_without_a_dashscope_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False)
monkeypatch.setattr(litellm, "api_key", None)
headers: Final = {"x-api-key": "caller-key"}
resolved_headers, _ = DashScopeAnthropicMessagesConfig().validate_anthropic_messages_environment(
headers=headers,
model="qwen3.7-plus",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
api_base=WORKSPACE,
)
assert resolved_headers["x-api-key"] == "caller-key"
def test_caller_authorization_header_is_honored_without_a_dashscope_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False)
monkeypatch.setattr(litellm, "api_key", None)
headers: Final = {"Authorization": "Bearer caller-token"}
resolved_headers, _ = DashScopeAnthropicMessagesConfig().validate_anthropic_messages_environment(
headers=headers,
model="qwen3.7-plus",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
api_base=WORKSPACE,
)
assert resolved_headers["Authorization"] == "Bearer caller-token"
def test_dashscope_key_wins_over_an_anthropic_environment_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-dashscope")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-secret")
resolved_headers, _ = DashScopeAnthropicMessagesConfig().validate_anthropic_messages_environment(
headers={},
model="qwen3.7-plus",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
api_base=WORKSPACE,
)
header_values: Final = " ".join(resolved_headers.values())
assert "sk-dashscope" in header_values
assert "sk-ant-secret" not in header_values
def test_dashscope_prefix_reaches_the_provider():
model, provider, _, _ = litellm.get_llm_provider(
"dashscope/qwen3.7-plus",
api_base=WORKSPACE,
)
assert provider == "dashscope"
assert model == "qwen3.7-plus"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("api_base", "expected_url"),
[
(INTL, f"{INTL}/apps/anthropic/v1/messages"),
(f"{INTL}/apps/anthropic", f"{INTL}/apps/anthropic/v1/messages"),
(WORKSPACE, f"{WORKSPACE}/apps/anthropic/v1/messages"),
(f"{CODING_PLAN}/apps/anthropic", f"{CODING_PLAN}/apps/anthropic/v1/messages"),
(None, "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"),
(f"{INTL}/compatible-mode/v1", f"{INTL}/compatible-mode/v1/chat/completions"),
(f"{CODING_PLAN}/v1", f"{CODING_PLAN}/v1/chat/completions"),
("https://gateway.example.com/anthropic", "https://gateway.example.com/anthropic/chat/completions"),
],
)
async def test_only_a_domain_root_or_anthropic_base_leaves_the_chat_bridge(
monkeypatch: pytest.MonkeyPatch, api_base: str | None, expected_url: str
):
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
with respx.mock(assert_all_called=False) as upstream:
upstream.route(path__regex=r".*/chat/completions$").respond(200, json=CHAT_COMPLETION)
upstream.route(path__regex=r".*/v1/messages$").respond(200, json=MESSAGE)
await litellm.anthropic.messages.acreate(
model="dashscope/qwen3.7-plus",
messages=[{"role": "user", "content": "say ok"}],
max_tokens=16,
api_key="sk-dashscope",
api_base=api_base,
)
assert [str(call.request.url) for call in upstream.calls] == [expected_url]

View file

@ -3,12 +3,15 @@ Unit tests for DashScope rerank transformation.
"""
import json
from typing import Final
from unittest.mock import MagicMock
import httpx
import pytest
import respx
from pydantic import ValidationError
import litellm
from litellm.llms.dashscope.common_utils import DashScopeError
from litellm.llms.dashscope.rerank.transformation import (
@ -26,6 +29,21 @@ class TestDashScopeRerankURL:
url = self.config.get_complete_url(api_base=None, model="qwen3-rerank")
assert url == DEFAULT_RERANK_URL
def test_domain_root_remaps_to_rerank_route(self):
url: Final = self.config.get_complete_url(
api_base="https://llm-abc123.cn-beijing.maas.aliyuncs.com",
model="qwen3-rerank",
)
assert url == "https://llm-abc123.cn-beijing.maas.aliyuncs.com/compatible-api/v1/reranks"
def test_explicit_rerank_url_wins_over_the_rerank_env(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("DASHSCOPE_API_BASE_RERANK", DEFAULT_RERANK_URL)
explicit_url: Final = "https://dashscope-intl.aliyuncs.com/compatible-api/v1/reranks"
url: Final = self.config.get_complete_url(api_base=explicit_url, model="qwen3-rerank")
assert url == explicit_url
def test_chat_shaped_base_remaps_to_rerank_route(self):
url = self.config.get_complete_url(
api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
@ -434,3 +452,37 @@ def test_transform_rerank_response_shape_errors_do_not_echo_the_payload():
_transform({"results": [["leaked document text"]]})
assert "leaked document text" not in str(exc_info.value)
@pytest.mark.parametrize(
("provider", "rerank_env"),
[
("dashscope", "DASHSCOPE_API_BASE_RERANK"),
("qwencloud", "QWENCLOUD_API_BASE_RERANK"),
("qwen_ai_platform", "QWEN_AI_PLATFORM_API_BASE_RERANK"),
],
)
@pytest.mark.parametrize(
"api_base",
[
"https://dashscope-intl.aliyuncs.com/compatible-api/v1/reranks",
"https://dashscope-intl.aliyuncs.com/compatible-api/v1",
"https://dashscope-intl.aliyuncs.com",
"https://dashscope-intl.aliyuncs.com/api/v1",
],
)
def test_rerank_sends_an_explicit_base_over_the_rerank_env(
monkeypatch: pytest.MonkeyPatch, provider: str, rerank_env: str, api_base: str
):
monkeypatch.setenv("DASHSCOPE_API_BASE", "https://dashscope-intl.aliyuncs.com")
monkeypatch.setenv(rerank_env, DEFAULT_RERANK_URL)
with respx.mock(assert_all_called=True) as upstream:
upstream.post("https://dashscope-intl.aliyuncs.com/compatible-api/v1/reranks").respond(
200, json={"id": "r1", "results": [{"index": 0, "relevance_score": 0.9}], "usage": {"total_tokens": 5}}
)
response: Final = litellm.rerank(
model=f"{provider}/qwen3-rerank", query="q", documents=["a"], api_base=api_base, api_key="sk-test"
)
assert response.results == [{"index": 0, "relevance_score": 0.9}]

View file

@ -1119,6 +1119,20 @@ class TestDashScopeVideoEnvironment:
assert url == "https://ws-123.cn-beijing.maas.aliyuncs.com"
def test_every_protocol_base_normalizes_to_the_host(self):
workspace: Final = "https://ws-123.cn-beijing.maas.aliyuncs.com"
for api_base in (
f"{workspace}/compatible-mode/v1",
f"{workspace}/apps/anthropic",
f"{workspace}/api/v1",
):
url: Final = DashScopeVideoConfig().get_complete_url(
model="wan3.0-video", api_base=api_base, litellm_params={}
)
assert url == workspace
def test_default_host_is_beijing(self, monkeypatch):
monkeypatch.delenv("DASHSCOPE_API_BASE_VIDEO", raising=False)
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)

View file

@ -2,8 +2,10 @@ import json
import math
import re
from pathlib import Path
from typing import Final, Literal
import pytest
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm import completion, get_llm_provider
@ -53,6 +55,23 @@ DASHSCOPE_FAMILY_ENV_VARS = [
"QWEN_AI_PLATFORM_API_BASE_IMAGE",
]
class BrandCase(TypedDict):
provider: ReadOnly[str]
display_name: ReadOnly[str]
enum: ReadOnly[LlmProviders]
key_env: ReadOnly[str]
base_env: ReadOnly[str]
default_base: ReadOnly[str]
default_rerank_base: ReadOnly[str]
default_image_base: ReadOnly[str]
chat_config: ReadOnly[type[DashScopeChatConfig]]
embedding_config: ReadOnly[type[DashScopeEmbeddingConfig]]
rerank_config: ReadOnly[type[DashScopeRerankConfig]]
image_config: ReadOnly[type[DashScopeImageGenerationConfig]]
BRAND_CASES = [
pytest.param(
{
@ -211,6 +230,20 @@ class TestQwenBrandDefaultUrls:
)
assert url == f"{brand['default_base']}/embeddings"
@pytest.mark.parametrize("brand", BRAND_CASES)
@pytest.mark.parametrize(("config_key", "route"), [("chat_config", "chat/completions"), ("embedding_config", "embeddings")])
def test_openai_routes_derive_from_a_domain_root(
self, brand: BrandCase, config_key: Literal["chat_config", "embedding_config"], route: str
) -> None:
url: Final = brand[config_key]().get_complete_url(
api_base="https://dashscope-intl.aliyuncs.com",
api_key="sk-test",
model="qwen-max",
optional_params={},
litellm_params={},
)
assert url == f"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/{route}"
@pytest.mark.parametrize("brand", BRAND_CASES)
def test_rerank_complete_url(self, brand):
url = brand["rerank_config"]().get_complete_url(api_base=None, model="gte-rerank-v2")
@ -249,6 +282,19 @@ class TestQwenBrandDefaultUrls:
)
assert url == brand["default_image_base"]
@pytest.mark.parametrize("brand", BRAND_CASES)
def test_image_generation_uses_its_own_configured_base(self, monkeypatch: pytest.MonkeyPatch, brand: BrandCase) -> None:
monkeypatch.setenv("DASHSCOPE_API_BASE", "https://legacy.example.com/images")
monkeypatch.setenv(brand["base_env"], "https://gateway.example.com/qwen/images")
url: Final = brand["image_config"]().get_complete_url(
api_base=None,
api_key="sk-test",
model="qwen-image",
optional_params={},
litellm_params={},
)
assert url == "https://gateway.example.com/qwen/images"
@pytest.mark.parametrize("brand", BRAND_CASES)
def test_validate_environment_requires_key(self, brand):
with pytest.raises(ValueError, match="DASHSCOPE_API_KEY"):

View file

@ -5,21 +5,26 @@ qwen-image-3.0, qwen-image-3.0-pro).
Run in docker: pytest tests/unit/test_dashscope_image_generation.py -v
"""
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
import pytest
import respx
from pydantic import ValidationError
import litellm
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.dashscope.common_utils import (
DASHSCOPE_DEFAULT_ORIGIN,
DASHSCOPE_IMAGE_PATH,
)
from litellm.llms.dashscope.image_generation.transformation import (
DashScopeImageGenerationConfig,
DEFAULT_API_BASE,
DashScopeImageGenerationConfig,
)
from litellm.types.utils import ImageResponse
from litellm.utils import get_llm_provider
from litellm.llms.base_llm.chat.transformation import BaseLLMException
# ---------------------------------------------------------------------------
# 1. Provider detection
@ -77,6 +82,47 @@ class TestDashScopeImageGenerationConfig:
url = self.cfg.get_complete_url(chat_api_base, None, "qwen-image-3.0", {}, {})
assert url == DEFAULT_API_BASE
@pytest.mark.parametrize(
("api_base", "expected"),
[
(
"https://dashscope.aliyuncs.com",
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
),
(
"https://llm-abc123.cn-beijing.maas.aliyuncs.com",
"https://llm-abc123.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
),
(
"https://llm-abc123.cn-beijing.maas.aliyuncs.com/apps/anthropic",
"https://llm-abc123.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
),
(
"https://dashscope-us.aliyuncs.com/api/v1/",
"https://dashscope-us.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
),
],
)
def test_get_complete_url_derives_from_the_domain(
self, api_base: str, expected: str
):
url: Final = self.cfg.get_complete_url(api_base, None, "qwen-image-3.0", {}, {})
assert url == expected
@pytest.mark.parametrize(
"endpoint_url",
[
"https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation",
"https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis",
],
)
def test_get_complete_url_keeps_a_full_dashscope_endpoint_url(
self, monkeypatch: pytest.MonkeyPatch, endpoint_url: str
):
monkeypatch.setenv("DASHSCOPE_API_BASE_IMAGE", "https://images.example.com/generate")
url: Final = self.cfg.get_complete_url(endpoint_url, None, "qwen-image-3.0", {}, {})
assert url == endpoint_url
def test_validate_environment_sets_auth_header(self):
headers = self.cfg.validate_environment(
headers={},
@ -458,6 +504,81 @@ def test_litellm_image_generation_dashscope_end_to_end(model: str):
assert body["parameters"]["size"] == "1024*1024"
@pytest.mark.parametrize(
("api_base", "chat_base_env", "image_base_env", "expected_url"),
[
(None, "", "", DEFAULT_API_BASE),
("https://dashscope.aliyuncs.com", "", "", f"{DASHSCOPE_DEFAULT_ORIGIN}{DASHSCOPE_IMAGE_PATH}"),
(None, "", "https://images.example.com/generate", "https://images.example.com/generate"),
(
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"",
"https://images.example.com/generate",
"https://images.example.com/generate",
),
(None, "https://gateway.example.com/dashscope/images", "", "https://gateway.example.com/dashscope/images"),
(None, "https://dashscope-us.aliyuncs.com", "https://images.example.com/generate", "https://images.example.com/generate"),
(
None,
f"https://dashscope-us.aliyuncs.com{DASHSCOPE_IMAGE_PATH}",
"https://images.example.com/generate",
f"https://dashscope-us.aliyuncs.com{DASHSCOPE_IMAGE_PATH}",
),
(
None,
"https://gateway.example.com/dashscope/images",
"https://images.example.com/generate",
"https://gateway.example.com/dashscope/images",
),
(
None,
"https://dashscope-us.aliyuncs.com/compatible-mode/v1",
"https://images.example.com/generate",
"https://images.example.com/generate",
),
(
"https://dashscope-us.aliyuncs.com",
"",
"https://images.example.com/generate",
f"https://dashscope-us.aliyuncs.com{DASHSCOPE_IMAGE_PATH}",
),
(
None,
"https://dashscope-us.aliyuncs.com",
"",
f"https://dashscope-us.aliyuncs.com{DASHSCOPE_IMAGE_PATH}",
),
(
"https://dashscope-intl.aliyuncs.com",
"https://gateway.example.com/dashscope/images",
"",
f"https://dashscope-intl.aliyuncs.com{DASHSCOPE_IMAGE_PATH}",
),
],
)
def test_image_generation_resolves_from_the_configured_base_not_the_chat_default(
monkeypatch: pytest.MonkeyPatch,
api_base: str | None,
chat_base_env: str,
image_base_env: str,
expected_url: str,
):
monkeypatch.setenv("DASHSCOPE_API_BASE", chat_base_env)
monkeypatch.setenv("DASHSCOPE_API_BASE_IMAGE", image_base_env)
image_url: Final = "https://dashscope-result.oss.aliyuncs.com/t.png"
with respx.mock(assert_all_called=True) as upstream:
upstream.post(expected_url).respond(
200, json={"output": {"choices": [{"message": {"content": [{"image": image_url}]}}]}}
)
response: Final = litellm.image_generation(
model="dashscope/qwen-image-3.0", prompt="a puppy", api_key="sk-test-key", api_base=api_base
)
assert response.data[0].url == image_url
def _transform_response(payload: object) -> ImageResponse:
return DashScopeImageGenerationConfig().transform_image_generation_response(
model="qwen-image-2.0",