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