mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
Merge aa645f1695 into ec3f8183c3
This commit is contained in:
commit
b21de9cd8b
8 changed files with 323 additions and 0 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
5
litellm/llms/dashscope/responses/__init__.py
Normal file
5
litellm/llms/dashscope/responses/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""DashScope Responses API module."""
|
||||
|
||||
from .transformation import DashScopeResponsesAPIConfig
|
||||
|
||||
__all__ = ("DashScopeResponsesAPIConfig",)
|
||||
94
litellm/llms/dashscope/responses/transformation.py
Normal file
94
litellm/llms/dashscope/responses/transformation.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue