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
This commit is contained in:
mateo-berri 2026-10-28 11:34:39 -07:00
parent 22a8358a43
commit be9f9afecb
13 changed files with 127 additions and 56 deletions

View file

@ -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}"

View file

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

View file

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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={}
)

View file

@ -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"):

View file

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