This commit is contained in:
Yassin Yasser 2026-09-01 16:08:52 +08:00 committed by GitHub
commit b21de9cd8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 323 additions and 0 deletions

View file

@ -2014,6 +2014,9 @@ if TYPE_CHECKING:
from .llms.dashscope.rerank.transformation import (
DashScopeRerankConfig as DashScopeRerankConfig,
)
from .llms.dashscope.responses.transformation import (
DashScopeResponsesAPIConfig as DashScopeResponsesAPIConfig,
)
from .llms.modelscope.chat.transformation import (
ModelScopeChatConfig as ModelScopeChatConfig,
)

View file

@ -310,6 +310,7 @@ LLM_CONFIG_NAMES: Final = (
"GigaChatConfig",
"GigaChatEmbeddingConfig",
"DashScopeChatConfig",
"DashScopeResponsesAPIConfig",
"ModelScopeChatConfig",
"MoonshotChatConfig",
"DockerModelRunnerChatConfig",
@ -1172,6 +1173,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
".llms.dashscope.chat.transformation",
"DashScopeChatConfig",
),
"DashScopeResponsesAPIConfig": (
".llms.dashscope.responses.transformation",
"DashScopeResponsesAPIConfig",
),
"GDCGeminiConfig": (
".llms.gdc.chat.transformation",
"GDCGeminiConfig",

View file

@ -0,0 +1,5 @@
"""DashScope Responses API module."""
from .transformation import DashScopeResponsesAPIConfig
__all__ = ("DashScopeResponsesAPIConfig",)

View file

@ -0,0 +1,94 @@
from typing import Final
import litellm
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import ALL_RESPONSES_API_TOOL_PARAMS, ResponseInputParam
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from ...openai.responses.transformation import OpenAIResponsesAPIConfig
DEFAULT_DASHSCOPE_API_BASE: Final[str] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
class DashScopeResponsesAPIConfig(OpenAIResponsesAPIConfig):
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.DASHSCOPE
def remove_cache_control_flag_from_input_and_tools(
self,
model: str,
input: str | ResponseInputParam,
tools: list[ALL_RESPONSES_API_TOOL_PARAMS] # mutable-ok: override BaseResponsesAPIConfig signature
| None = None, # mutable-ok: override BaseResponsesAPIConfig signature
) -> tuple[ # mutable-ok: override BaseResponsesAPIConfig signature
str | ResponseInputParam,
list[ALL_RESPONSES_API_TOOL_PARAMS] | None, # mutable-ok: override BaseResponsesAPIConfig signature
]:
"""
Override to preserve cache_control for DashScope.
DashScope supports cache_control - don't strip it.
"""
return input, tools
def validate_environment(
self,
headers: dict, # mutable-ok: override BaseResponsesAPIConfig signature
model: str,
litellm_params: GenericLiteLLMParams | None,
) -> dict: # mutable-ok: override BaseResponsesAPIConfig signature
resolved_params: Final = litellm_params or GenericLiteLLMParams()
api_key: Final = resolved_params.api_key or litellm.api_key or get_secret_str("DASHSCOPE_API_KEY")
if not api_key:
raise ValueError(
"DashScope API key is required. Set DASHSCOPE_API_KEY environment variable or pass api_key parameter."
)
headers.update(
{ # mutable-ok: header dict update
"Authorization": f"Bearer {api_key}",
}
)
return headers
def get_complete_url(
self,
api_base: str | None,
litellm_params: dict, # mutable-ok: override BaseResponsesAPIConfig signature
) -> str:
"""
Get the endpoint for DashScope Responses API.
Resolves the base URL in order:
1. litellm_params["responses_api_base"] (dedicated Responses base URL, e.g. for MaaS workspace URLs)
2. DASHSCOPE_RESPONSES_API_BASE environment variable
3. api_base passed in request or litellm_params["api_base"]
4. DASHSCOPE_API_BASE environment variable
5. Default: https://dashscope.aliyuncs.com/compatible-mode/v1
"""
raw_base_url: Final[str] = str(
litellm_params.get("responses_api_base")
or get_secret_str("DASHSCOPE_RESPONSES_API_BASE")
or api_base
or litellm.api_base
or get_secret_str("DASHSCOPE_API_BASE")
or DEFAULT_DASHSCOPE_API_BASE
)
# Remove trailing slashes
clean_base_url: Final[str] = raw_base_url.rstrip("/")
if clean_base_url.endswith("/responses"):
return clean_base_url
if clean_base_url.endswith("/v1"):
return f"{clean_base_url}/responses"
return f"{clean_base_url}/v1/responses"
def supports_native_websocket(self) -> bool:
"""DashScope does not support native WebSocket for Responses API"""
return False

View file

@ -302,6 +302,7 @@ def _build_banned_observability_params() -> frozenset[str]:
_BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = (
"api_base",
"responses_api_base",
"base_url",
"user_config",
"aws_sts_endpoint",

View file

@ -8694,6 +8694,8 @@ class ProviderConfigManager:
return litellm.LiteLLMProxyResponsesAPIConfig()
elif litellm.LlmProviders.VOLCENGINE == provider:
return litellm.VolcEngineResponsesAPIConfig()
elif litellm.LlmProviders.DASHSCOPE == provider:
return litellm.DashScopeResponsesAPIConfig()
elif litellm.LlmProviders.MANUS == provider:
return litellm.ManusResponsesAPIConfig()
elif litellm.LlmProviders.PERPLEXITY == provider:

View file

@ -31,6 +31,7 @@ EXCLUDED_GUARD_ONLY_VARS = {
EXCLUDED_ROLLOUT_FLAGS = {
"LITELLM_USE_RUST_OCR",
"LITELLM_RUST",
"DASHSCOPE_RESPONSES_API_BASE",
}
# Internal infrastructure tuning parameters for streaming/queue management

View file

@ -0,0 +1,212 @@
"""
Tests for DashScope Responses API support (Issue #38474).
"""
import json
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
import litellm
from litellm.llms.dashscope.responses.transformation import (
DEFAULT_DASHSCOPE_API_BASE,
DashScopeResponsesAPIConfig,
)
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
def _make_mock_responses_api_response(content: str = "Hello from Qwen!") -> dict[str, Any]:
return {
"id": "resp-dashscope-123",
"object": "response",
"created_at": 1234567890,
"model": "qwen3.8-flash",
"output": [
{
"type": "message",
"id": "msg-dashscope-123",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": content,
"annotations": [],
}
],
}
],
"status": "completed",
"usage": {
"input_tokens": 8,
"output_tokens": 12,
"total_tokens": 20,
},
}
def _make_mock_http_client(response_body: dict[str, Any]) -> MagicMock:
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.json.return_value = response_body
mock_response.text = json.dumps(response_body)
mock_client.post.return_value = mock_response
return mock_client
def test_dashscope_provider_config_registration() -> None:
"""Test that ProviderConfigManager returns DashScopeResponsesAPIConfig for dashscope."""
config = ProviderConfigManager.get_provider_responses_api_config(
model="dashscope/qwen3.8-flash",
provider=LlmProviders.DASHSCOPE,
)
assert config is not None
assert isinstance(config, DashScopeResponsesAPIConfig)
assert config.custom_llm_provider == LlmProviders.DASHSCOPE
def test_dashscope_default_url() -> None:
"""Test default public DashScope /responses URL."""
config = DashScopeResponsesAPIConfig()
url = config.get_complete_url(api_base=None, litellm_params={})
assert url == f"{DEFAULT_DASHSCOPE_API_BASE}/responses"
assert url == "https://dashscope.aliyuncs.com/compatible-mode/v1/responses"
def test_dashscope_maas_workspace_responses_api_base_in_params() -> None:
"""
Test MaaS workspace endpoint with dedicated responses_api_base in litellm_params.
This validates the core fix for Issue #38474.
"""
config = DashScopeResponsesAPIConfig()
maas_chat_base = "https://ws-12345.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"
maas_responses_base = "https://ws-12345.ap-southeast-1.maas.aliyuncs.com/api/v2/apps/protocols/compatible-mode/v1"
url = config.get_complete_url(
api_base=maas_chat_base,
litellm_params={"responses_api_base": maas_responses_base},
)
assert url == f"{maas_responses_base}/responses"
def test_dashscope_responses_api_base_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test DASHSCOPE_RESPONSES_API_BASE environment variable takes precedence."""
config = DashScopeResponsesAPIConfig()
monkeypatch.setenv(
"DASHSCOPE_RESPONSES_API_BASE",
"https://custom-ws.maas.aliyuncs.com/api/v2/apps/protocols/compatible-mode/v1",
)
monkeypatch.setenv(
"DASHSCOPE_API_BASE",
"https://custom-ws.maas.aliyuncs.com/compatible-mode/v1",
)
url = config.get_complete_url(api_base=None, litellm_params={})
assert url == "https://custom-ws.maas.aliyuncs.com/api/v2/apps/protocols/compatible-mode/v1/responses"
def test_dashscope_api_base_without_v1() -> None:
"""Test api_base without /v1 suffix correctly appends /v1/responses."""
config = DashScopeResponsesAPIConfig()
url = config.get_complete_url(
api_base="https://custom-dashscope.example.com",
litellm_params={},
)
assert url == "https://custom-dashscope.example.com/v1/responses"
def test_dashscope_api_base_with_trailing_responses() -> None:
"""Test api_base already ending with /responses is not duplicated."""
config = DashScopeResponsesAPIConfig()
url = config.get_complete_url(
api_base="https://custom-dashscope.example.com/compatible-mode/v1/responses",
litellm_params={},
)
assert url == "https://custom-dashscope.example.com/compatible-mode/v1/responses"
def test_dashscope_validate_environment_success() -> None:
"""Test validate_environment sets Authorization header."""
config = DashScopeResponsesAPIConfig()
headers = config.validate_environment(
headers={},
model="qwen3.8-flash",
litellm_params=GenericLiteLLMParams(api_key="sk-test-key-123"),
)
assert headers.get("Authorization") == "Bearer sk-test-key-123"
def test_dashscope_validate_environment_missing_key() -> None:
"""Test validate_environment raises ValueError when API key is missing."""
config = DashScopeResponsesAPIConfig()
with pytest.raises(ValueError, match="DashScope API key is required"):
config.validate_environment(
headers={},
model="qwen3.8-flash",
litellm_params=GenericLiteLLMParams(api_key=None),
)
def test_dashscope_preserves_cache_control() -> None:
"""Test DashScopeResponsesAPIConfig preserves cache_control in tools/input."""
config = DashScopeResponsesAPIConfig()
sample_input = [
{"role": "user", "content": [{"type": "input_text", "text": "Hi", "cache_control": {"type": "ephemeral"}}]}
]
input_res, _ = config.remove_cache_control_flag_from_input_and_tools(
model="qwen3.8-flash",
input=sample_input, # pyright: ignore[reportArgumentType] # test exercises supported cache-control input
tools=None,
)
assert isinstance(input_res, list)
assert input_res == sample_input
first_item = input_res[0]
assert isinstance(first_item, dict)
content_list = first_item.get("content")
assert isinstance(content_list, list)
assert "cache_control" in content_list[0]
def test_dashscope_supports_native_websocket() -> None:
"""Test supports_native_websocket returns False."""
config = DashScopeResponsesAPIConfig()
assert config.supports_native_websocket() is False
def test_dashscope_responses_e2e_with_mock_client() -> None:
"""Test end-to-end litellm.responses call for dashscope."""
mock_client = _make_mock_http_client(_make_mock_responses_api_response("Hello from Qwen!"))
with patch( # test-quality-ok: mock HTTP client for Responses API without live network
"litellm.llms.custom_httpx.llm_http_handler._get_httpx_client",
return_value=mock_client,
):
response = litellm.responses(
model="dashscope/qwen3.8-flash",
input="Hello",
api_key="sk-dashscope-test",
responses_api_base="https://ws.maas.aliyuncs.com/api/v2/apps/protocols/compatible-mode/v1",
)
assert response is not None
assert isinstance(response, ResponsesAPIResponse)
assert len(response.output) > 0
msg = response.output[0]
assert getattr(msg, "role", None) == "assistant"
content_list = getattr(msg, "content", None)
assert content_list is not None
assert content_list[0].text == "Hello from Qwen!"
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
called_url = call_args[0][0] if call_args[0] else call_args[1].get("url")
assert "https://ws.maas.aliyuncs.com/api/v2/apps/protocols/compatible-mode/v1/responses" in str(called_url)