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

This commit is contained in:
mateo-berri 2026-10-28 07:33:33 -07:00
parent aba5477124
commit 22a8358a43
8 changed files with 176 additions and 17 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)
@ -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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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