feat(dashscope): add native Responses API support

DashScope (Alibaba Cloud) provides an OpenAI-compatible `/v1/responses`
endpoint. This adds a `DashScopeResponsesAPIConfig` so that LiteLLM can
route Responses API calls to DashScope natively, enabling
`previous_response_id` sticky routing and server-side context caching.

Changes:
- New `litellm/llms/dashscope/responses/transformation.py`
- Register in `ProviderConfigManager._get_python_responses_api_config()`
- Add lazy import entry in `_lazy_imports_registry.py`
- Unit tests covering registration, URL construction, auth, and params
This commit is contained in:
V 2026-03-18 12:01:57 +08:00
parent e3d4c29d37
commit d6ba114aff
6 changed files with 328 additions and 0 deletions

View file

@ -299,6 +299,7 @@ LLM_CONFIG_NAMES = (
"GigaChatConfig",
"GigaChatEmbeddingConfig",
"DashScopeChatConfig",
"DashScopeResponsesAPIConfig",
"MoonshotChatConfig",
"DockerModelRunnerChatConfig",
"V0ChatConfig",
@ -1125,6 +1126,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
".llms.dashscope.chat.transformation",
"DashScopeChatConfig",
),
"DashScopeResponsesAPIConfig": (
".llms.dashscope.responses.transformation",
"DashScopeResponsesAPIConfig",
),
"MoonshotChatConfig": (".llms.moonshot.chat.transformation", "MoonshotChatConfig"),
"DockerModelRunnerChatConfig": (
".llms.docker_model_runner.chat.transformation",

View file

@ -0,0 +1,147 @@
"""
Translates from OpenAI's `/v1/responses` to DashScope's `/compatible-mode/v1/responses`
DashScope (Alibaba Cloud) provides an OpenAI-compatible endpoint, so most of
the heavy lifting is delegated to ``OpenAIResponsesAPIConfig``. This subclass
only overrides provider identification, authentication, URL construction, and
the supported-parameter whitelist.
"""
from typing import Dict, List, Optional, Union
import httpx
import litellm
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
_DEFAULT_API_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
_SUPPORTED_OPTIONAL_PARAMS: List[str] = [
"instructions",
"max_output_tokens",
"metadata",
"previous_response_id",
"reasoning",
"store",
"stream",
"temperature",
"text",
"tools",
"tool_choice",
"top_p",
# LiteLLM request plumbing helpers
"extra_headers",
"extra_query",
"extra_body",
"timeout",
]
class DashScopeResponsesAPIConfig(OpenAIResponsesAPIConfig):
"""Responses API configuration for DashScope (Alibaba Cloud)."""
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.DASHSCOPE
def get_supported_openai_params(self, model: str) -> list:
"""Return the parameter whitelist for DashScope Responses API."""
supported = ["input", "model"] + list(_SUPPORTED_OPTIONAL_PARAMS)
# metadata is LiteLLM-internal; advertise all others
if "metadata" in supported:
supported.remove("metadata")
return supported
def map_openai_params(
self,
response_api_optional_params: ResponsesAPIOptionalRequestParams,
model: str,
drop_params: bool,
) -> Dict:
"""Filter parameters to the DashScope-supported set."""
params = {
key: value
for key, value in dict(response_api_optional_params).items()
if key in _SUPPORTED_OPTIONAL_PARAMS
}
# LiteLLM metadata is internal-only; don't send to provider
params.pop("metadata", None)
return params
def validate_environment(
self,
headers: dict,
model: str,
litellm_params: Optional[GenericLiteLLMParams],
) -> dict:
"""Build auth headers for DashScope Responses API."""
if litellm_params is None:
litellm_params = GenericLiteLLMParams()
elif isinstance(litellm_params, dict):
litellm_params = GenericLiteLLMParams(**litellm_params)
api_key = (
litellm_params.api_key
or litellm.api_key
or get_secret_str("DASHSCOPE_API_KEY")
)
if api_key is None:
raise ValueError(
"DashScope API key is required. "
"Set DASHSCOPE_API_KEY or pass api_key."
)
result_headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
if headers:
result_headers.update(headers)
return result_headers
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
"""Construct DashScope Responses API endpoint."""
base_url = (
api_base
or litellm.api_base
or get_secret_str("DASHSCOPE_API_BASE")
or _DEFAULT_API_BASE
)
base_url = base_url.rstrip("/")
if base_url.endswith("/responses"):
return base_url
if base_url.endswith("/v1"):
return f"{base_url}/responses"
if base_url.endswith("/compatible-mode/v1"):
return f"{base_url}/responses"
return f"{base_url}/compatible-mode/v1/responses"
def get_error_class(
self,
error_message: str,
status_code: int,
headers: Union[dict, httpx.Headers],
) -> Exception:
from litellm.llms.openai.common_utils import OpenAIError
typed_headers: httpx.Headers = (
headers
if isinstance(headers, httpx.Headers)
else httpx.Headers(headers or {})
)
return OpenAIError(
status_code=status_code,
message=error_message,
headers=typed_headers,
)

View file

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

View file

@ -0,0 +1,174 @@
"""
Tests for DashScope Responses API transformation.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
import litellm
from litellm.llms.dashscope.responses.transformation import (
DashScopeResponsesAPIConfig,
)
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
class TestDashScopeResponsesAPITransformation:
"""Test DashScope Responses API configuration and transformations."""
def test_provider_config_registration(self):
"""Provider registry should return DashScopeResponsesAPIConfig."""
config = ProviderConfigManager.get_provider_responses_api_config(
model="dashscope/qwen-plus",
provider=LlmProviders.DASHSCOPE,
)
assert config is not None, "Config should not be None for DashScope provider"
assert isinstance(
config, DashScopeResponsesAPIConfig
), f"Expected DashScopeResponsesAPIConfig, got {type(config)}"
assert (
config.custom_llm_provider == LlmProviders.DASHSCOPE
), "custom_llm_provider should be DASHSCOPE"
def test_get_complete_url_default(self):
"""Default URL should point to DashScope compatible-mode endpoint."""
config = DashScopeResponsesAPIConfig()
url = config.get_complete_url(api_base=None, litellm_params={})
assert url == "https://dashscope.aliyuncs.com/compatible-mode/v1/responses"
def test_get_complete_url_custom_base(self):
"""Custom api_base should be respected."""
config = DashScopeResponsesAPIConfig()
# Base ending with /v1
url = config.get_complete_url(
api_base="https://custom.example.com/v1", litellm_params={}
)
assert url == "https://custom.example.com/v1/responses"
# Base already ending with /responses
url = config.get_complete_url(
api_base="https://custom.example.com/v1/responses", litellm_params={}
)
assert url == "https://custom.example.com/v1/responses"
# Bare domain
url = config.get_complete_url(
api_base="https://custom.example.com", litellm_params={}
)
assert url == "https://custom.example.com/compatible-mode/v1/responses"
@pytest.mark.parametrize(
"litellm_params, expected_key",
[
({"api_key": "dict-key"}, "dict-key"),
(GenericLiteLLMParams(api_key="attr-key"), "attr-key"),
],
)
def test_validate_environment_uses_api_key(
self, monkeypatch, litellm_params, expected_key
):
"""validate_environment should pull api key from params/env and attach headers."""
config = DashScopeResponsesAPIConfig()
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False)
headers = config.validate_environment(
headers={}, model="dashscope/qwen-plus", litellm_params=litellm_params
)
assert headers.get("Authorization") == f"Bearer {expected_key}"
assert headers.get("Content-Type") == "application/json"
def test_validate_environment_from_env(self, monkeypatch):
"""validate_environment should fall back to DASHSCOPE_API_KEY env var."""
config = DashScopeResponsesAPIConfig()
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.setenv("DASHSCOPE_API_KEY", "env-key")
headers = config.validate_environment(
headers={}, model="dashscope/qwen-plus", litellm_params={}
)
assert headers.get("Authorization") == "Bearer env-key"
def test_validate_environment_raises_without_key(self, monkeypatch):
"""validate_environment should error when no key is available."""
config = DashScopeResponsesAPIConfig()
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False)
with pytest.raises(ValueError, match="DashScope API key is required"):
config.validate_environment(
headers={}, model="dashscope/qwen-plus", litellm_params={}
)
def test_supported_params(self):
"""Supported params should match documented DashScope surface."""
config = DashScopeResponsesAPIConfig()
supported = set(config.get_supported_openai_params("dashscope/qwen-plus"))
expected = {
"input",
"model",
"instructions",
"max_output_tokens",
"previous_response_id",
"reasoning",
"store",
"stream",
"temperature",
"text",
"tools",
"tool_choice",
"top_p",
"extra_headers",
"extra_query",
"extra_body",
"timeout",
}
assert supported == expected
def test_map_openai_params_filters_unsupported(self):
"""map_openai_params should drop unsupported and metadata params."""
config = DashScopeResponsesAPIConfig()
params = ResponsesAPIOptionalRequestParams(
temperature=0.7,
metadata={"k": "v"},
)
mapped = config.map_openai_params(
response_api_optional_params=params,
model="dashscope/qwen-plus",
drop_params=False,
)
assert mapped.get("temperature") == 0.7
assert "metadata" not in mapped
def test_extra_headers_merged(self, monkeypatch):
"""Extra headers passed to validate_environment should be merged."""
config = DashScopeResponsesAPIConfig()
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key")
headers = config.validate_environment(
headers={"X-Custom": "value"},
model="dashscope/qwen-plus",
litellm_params={},
)
assert headers.get("X-Custom") == "value"
assert headers.get("Authorization") == "Bearer test-key"