From d6ba114aff91cf54af50d08309d954a4e7458138 Mon Sep 17 00:00:00 2001 From: V <815559417@qq.com> Date: Wed, 18 Mar 2026 12:01:57 +0800 Subject: [PATCH] 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 --- litellm/_lazy_imports_registry.py | 5 + litellm/llms/dashscope/responses/__init__.py | 0 .../dashscope/responses/transformation.py | 147 +++++++++++++++ litellm/utils.py | 2 + .../llms/dashscope/responses/__init__.py | 0 ...test_dashscope_responses_transformation.py | 174 ++++++++++++++++++ 6 files changed, 328 insertions(+) create mode 100644 litellm/llms/dashscope/responses/__init__.py create mode 100644 litellm/llms/dashscope/responses/transformation.py create mode 100644 tests/test_litellm/llms/dashscope/responses/__init__.py create mode 100644 tests/test_litellm/llms/dashscope/responses/test_dashscope_responses_transformation.py diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 9164a3c8ae4..6b6e3cef030 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -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", diff --git a/litellm/llms/dashscope/responses/__init__.py b/litellm/llms/dashscope/responses/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/dashscope/responses/transformation.py b/litellm/llms/dashscope/responses/transformation.py new file mode 100644 index 00000000000..d654bbfcf50 --- /dev/null +++ b/litellm/llms/dashscope/responses/transformation.py @@ -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, + ) diff --git a/litellm/utils.py b/litellm/utils.py index 088ee07d630..ffd588597a2 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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: diff --git a/tests/test_litellm/llms/dashscope/responses/__init__.py b/tests/test_litellm/llms/dashscope/responses/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/dashscope/responses/test_dashscope_responses_transformation.py b/tests/test_litellm/llms/dashscope/responses/test_dashscope_responses_transformation.py new file mode 100644 index 00000000000..9d4adca04e3 --- /dev/null +++ b/tests/test_litellm/llms/dashscope/responses/test_dashscope_responses_transformation.py @@ -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"