mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
fix(dashscope): keep the chat bridge for protocol-named bases and the image default for unconfigured calls
This commit is contained in:
parent
aba5477124
commit
22a8358a43
8 changed files with 176 additions and 17 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue