feat(dashscope): add image edits and text-to-speech

This commit is contained in:
mateo-berri 2026-10-28 18:42:58 -07:00
parent f89b9c6150
commit 82d37f17b5
16 changed files with 1451 additions and 43 deletions

View file

@ -135,6 +135,18 @@ class BaseTextToSpeechConfig(ABC):
Transform provider response to standard format
"""
async def async_transform_text_to_speech_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> "HttpxBinaryResponseContent":
return self.transform_text_to_speech_response(
model=model,
raw_response=raw_response,
logging_obj=logging_obj,
)
def get_error_class(self, error_message: str, status_code: int, headers: dict) -> BaseLLMException:
from ..chat.transformation import BaseLLMException

View file

@ -12729,7 +12729,7 @@ class BaseLLMHTTPHandler:
provider_config=text_to_speech_provider_config,
)
speech_response: Final = text_to_speech_provider_config.transform_text_to_speech_response(
speech_response: Final = await text_to_speech_provider_config.async_transform_text_to_speech_response(
model=model,
raw_response=response,
logging_obj=logging_obj,

View file

@ -0,0 +1,182 @@
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final, Protocol
import httpx
from httpx._types import RequestFiles
from pydantic import TypeAdapter
from typing_extensions import override
import litellm
from litellm.images.utils import image_to_data_url
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from litellm.llms.dashscope.image_generation.transformation import (
OPENAI_TO_DASHSCOPE_SIZE,
DashScopeImageGenerationConfig,
)
from litellm.types.images.main import ImageEditOptionalRequestParams
from litellm.types.llms.openai import AllMessageValues
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import ImageResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer
MAX_INPUT_IMAGES: Final = 3
DASHSCOPE_IMAGE_EDIT_PARAMS: Final = ("n", "size", "negative_prompt", "prompt_extend", "watermark", "seed")
_IMAGES: Final = TypeAdapter(tuple[object, ...])
def _dashscope_size(size: object) -> object:
if not isinstance(size, str):
return size
return OPENAI_TO_DASHSCOPE_SIZE.get(size, size.replace("x", "*"))
class DashScopeImageGeneration(Protocol):
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: mirrors the image generation contract
model: str,
messages: list[AllMessageValues], # mutable-ok: mirrors the image generation contract
optional_params: dict[str, object], # mutable-ok: mirrors the image generation contract
litellm_params: dict[str, object], # mutable-ok: mirrors the image generation contract
api_key: str | None = None,
api_base: str | None = None,
) -> dict[str, str]: ... # mutable-ok: mirrors the image generation contract
def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
optional_params: dict[str, object], # mutable-ok: mirrors the image generation contract
litellm_params: dict[str, object], # mutable-ok: mirrors the image generation contract
stream: bool | None = None,
) -> str: ...
def transform_image_generation_response(
self,
model: str,
raw_response: httpx.Response,
model_response: ImageResponse,
logging_obj: "LiteLLMLoggingObj",
request_data: dict[str, object], # mutable-ok: mirrors the image generation contract
optional_params: dict[str, object], # mutable-ok: mirrors the image generation contract
litellm_params: dict[str, object], # mutable-ok: mirrors the image generation contract
encoding: "Tokenizer | None",
api_key: str | None = None,
json_mode: bool | None = None,
) -> ImageResponse: ...
class DashScopeImageEditConfig(BaseImageEditConfig):
def __init__(self, generation_config: DashScopeImageGeneration | None = None) -> None:
super().__init__()
self._generation_config: Final[DashScopeImageGeneration] = generation_config or DashScopeImageGenerationConfig()
@override
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: mirrors override contract
return list(DASHSCOPE_IMAGE_EDIT_PARAMS)
@override
def map_openai_params(
self,
image_edit_optional_params: ImageEditOptionalRequestParams,
model: str,
drop_params: bool,
) -> dict[str, object]: # mutable-ok: mirrors override contract
return {
key: _dashscope_size(value) if key == "size" else value
for key, value in image_edit_optional_params.items()
if key in DASHSCOPE_IMAGE_EDIT_PARAMS
}
@override
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: mirrors override contract
model: str,
api_key: str | None = None,
litellm_params: dict[str, object] | None = None, # mutable-ok: mirrors override contract
api_base: str | None = None,
) -> dict[str, str]: # mutable-ok: mirrors override contract
return self._generation_config.validate_environment(
headers=dict(headers),
model=model,
messages=[],
optional_params={},
litellm_params=dict(litellm_params or {}),
api_key=api_key,
api_base=api_base,
)
@override
def get_complete_url(
self,
model: str,
api_base: str | None,
litellm_params: dict[str, object], # mutable-ok: mirrors override contract
) -> str:
return self._generation_config.get_complete_url(
api_base=api_base,
api_key=None,
model=model,
optional_params={},
litellm_params=litellm_params,
)
@override
def use_multipart_form_data(self) -> bool:
return False
@override
def transform_image_edit_request(
self,
model: str,
prompt: str | None,
image: object,
image_edit_optional_request_params: Mapping[str, object],
litellm_params: GenericLiteLLMParams,
headers: Mapping[str, str],
) -> tuple[dict[str, object], RequestFiles]: # mutable-ok: mirrors override contract
candidates: Final = _IMAGES.validate_python(image) if isinstance(image, list) else (image,)
images: Final = tuple(candidate for candidate in candidates if candidate is not None)
if len(images) > MAX_INPUT_IMAGES:
raise litellm.BadRequestError(
message=f"DashScope image edit accepts at most {MAX_INPUT_IMAGES} input images, got {len(images)}",
model=model,
llm_provider="dashscope",
)
prompt_content: Final = ({"text": prompt},) if prompt is not None else ()
return {
"model": model,
"input": {
"messages": [
{
"role": "user",
"content": [*({"image": image_to_data_url(item)} for item in images), *prompt_content],
}
]
},
"parameters": dict(image_edit_optional_request_params),
}, ()
@override
def transform_image_edit_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: "LiteLLMLoggingObj",
) -> ImageResponse:
return self._generation_config.transform_image_generation_response(
model=model,
raw_response=raw_response,
model_response=ImageResponse(),
logging_obj=logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)

View file

@ -56,7 +56,7 @@ DEFAULT_API_BASE: Final = "https://dashscope-intl.aliyuncs.com/api/v1/services/a
CHAT_COMPATIBLE_MODE_PATH: Final = "/compatible-mode/v1"
# Maps OpenAI size strings (WxH) to DashScope size strings (W*H)
OPENAI_TO_DASHSCOPE_SIZE: Final[dict] = {
OPENAI_TO_DASHSCOPE_SIZE: Final[Mapping[str, str]] = {
"256x256": "256*256",
"512x512": "512*512",
"1024x1024": "1024*1024",

View file

@ -0,0 +1,280 @@
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from itertools import chain
from typing import TYPE_CHECKING, Final
import httpx
from pydantic import BaseModel, ConfigDict, ValidationError
from typing_extensions import override
import litellm
from litellm.exceptions import UnsupportedParamsError
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get, safe_get
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.text_to_speech.transformation import (
BaseTextToSpeechConfig,
TextToSpeechRequestData,
)
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
HTTPHandler,
get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # factory stub leaves params untyped
get_httpx_client, # pyright: ignore[reportUnknownVariableType] # factory stub leaves params untyped
)
from litellm.llms.dashscope.common_utils import (
DASHSCOPE_CHAT_COMPATIBLE_PATH,
DashScopeError,
missing_dashscope_family_key_message,
resolve_dashscope_family_api_key,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import HttpxBinaryResponseContent
from litellm.types.utils import Usage
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
DASHSCOPE_TTS_DEFAULT_API_BASE: Final = "https://dashscope-intl.aliyuncs.com"
_DASHSCOPE_API_PATH: Final = "/api/v1"
_SPEECH_SYNTHESIZER_MODEL_PREFIX: Final = "qwen-audio-"
@dataclass(frozen=True, slots=True)
class _TextToSpeechFamily:
path: str
response_formats: frozenset[str]
sends_format: bool
takes_instructions: bool
MULTIMODAL_GENERATION_FAMILY: Final = _TextToSpeechFamily(
path="/api/v1/services/aigc/multimodal-generation/generation",
response_formats=frozenset({"wav"}),
sends_format=False,
takes_instructions=False,
)
SPEECH_SYNTHESIZER_FAMILY: Final = _TextToSpeechFamily(
path="/api/v1/services/audio/tts/SpeechSynthesizer",
response_formats=frozenset({"mp3", "wav", "opus", "pcm"}),
sends_format=True,
takes_instructions=True,
)
class _Audio(BaseModel):
model_config = ConfigDict(frozen=True)
url: str
class _Output(BaseModel):
model_config = ConfigDict(frozen=True)
audio: _Audio
class _Usage(BaseModel):
model_config = ConfigDict(frozen=True)
input_tokens: int | None = None
output_tokens: int | None = None
class _SpeechResponse(BaseModel):
model_config = ConfigDict(frozen=True)
output: _Output | None = None
usage: _Usage | None = None
code: str | None = None
message: str | None = None
def text_to_speech_family(model: str) -> _TextToSpeechFamily:
if model.startswith(_SPEECH_SYNTHESIZER_MODEL_PREFIX):
return SPEECH_SYNTHESIZER_FAMILY
return MULTIMODAL_GENERATION_FAMILY
def _api_root(api_base: str) -> str:
trimmed: Final = api_base.rstrip("/")
if trimmed.endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH):
return trimmed.removesuffix(DASHSCOPE_CHAT_COMPATIBLE_PATH)
root, separator, _ = trimmed.partition(_DASHSCOPE_API_PATH)
return root if separator else trimmed
def _mapped_field(family: _TextToSpeechFamily, key: str, value: object) -> Mapping[str, object] | None:
match key:
case "response_format" if value in family.response_formats:
return {"format": value} if family.sends_format else {}
case "instructions" if family.takes_instructions:
return {"instruction": value}
case _:
return None
def _unsupported(model: str, unsupported: tuple[str, ...]) -> UnsupportedParamsError:
return UnsupportedParamsError(
message=f"DashScope {model} does not support {', '.join(unsupported)}, set drop_params=True to ignore it",
model=model,
llm_provider="dashscope",
)
def _speech_response(raw_response: httpx.Response) -> _SpeechResponse:
try:
return _SpeechResponse.model_validate_json(raw_response.content)
except ValidationError as e:
raise DashScopeError(
status_code=502,
message=f"Unexpected DashScope text-to-speech response: {e}",
headers=raw_response.headers,
) from e
def _audio_url(raw_response: httpx.Response) -> tuple[str, Usage | None]:
parsed: Final = _speech_response(raw_response)
if parsed.output is None:
raise DashScopeError(
status_code=max(raw_response.status_code, 400),
message=parsed.message or parsed.code or raw_response.text,
headers=raw_response.headers,
)
return parsed.output.audio.url, _token_usage(parsed.usage)
def _token_usage(usage: _Usage | None) -> Usage | None:
if usage is None or usage.input_tokens is None or usage.output_tokens is None:
return None
return Usage(
prompt_tokens=usage.input_tokens,
completion_tokens=usage.output_tokens,
total_tokens=usage.input_tokens + usage.output_tokens,
)
def _refused_audio_url(error: SSRFError) -> DashScopeError:
return DashScopeError(status_code=502, message=f"DashScope returned an audio URL that was refused: {error}")
def _binary_audio(audio_response: httpx.Response, usage: Usage | None) -> HttpxBinaryResponseContent:
if audio_response.status_code != 200:
raise DashScopeError(
status_code=502,
message=f"DashScope audio download failed with HTTP {audio_response.status_code}",
headers=audio_response.headers,
)
binary: Final = HttpxBinaryResponseContent(audio_response)
binary.usage = usage
return binary
def _shared_async_client() -> AsyncHTTPHandler:
return get_async_httpx_client(llm_provider=litellm.LlmProviders.DASHSCOPE)
class DashScopeTextToSpeechConfig(BaseTextToSpeechConfig):
def __init__(
self,
sync_client: Callable[[], HTTPHandler] = get_httpx_client,
async_client: Callable[[], AsyncHTTPHandler] = _shared_async_client,
) -> None:
super().__init__()
self._sync_client: Final = sync_client
self._async_client: Final = async_client
@override
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: mirrors override contract
family: Final = text_to_speech_family(model)
return ["voice", "response_format", *(("instructions",) if family.takes_instructions else ())]
@override
def map_openai_params(
self,
model: str,
optional_params: dict[str, object], # mutable-ok: mirrors override contract
voice: str | dict[str, object] | None = None, # mutable-ok: mirrors override contract
drop_params: bool = False,
kwargs: Mapping[str, object] | None = None,
) -> tuple[str | None, dict[str, object]]: # mutable-ok: mirrors override contract
family: Final = text_to_speech_family(model)
fields: Final = tuple((key, value, _mapped_field(family, key, value)) for key, value in optional_params.items())
unsupported: Final = (
*(f"{key}={value!r}" for key, value, field in fields if field is None),
*(("voice as an object",) if isinstance(voice, dict) else ()),
)
if unsupported and not (drop_params or drops_unsupported_params((kwargs or {}).get("drop_params"))):
raise _unsupported(model, unsupported)
supported: Final = tuple(field for _, _, field in fields if field is not None)
return voice if isinstance(voice, str) else None, dict(chain.from_iterable(f.items() for f in supported))
@override
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: mirrors override contract
model: str,
api_key: str | None = None,
api_base: str | None = None,
) -> dict[str, str]: # mutable-ok: mirrors override contract
resolved_api_key: Final = resolve_dashscope_family_api_key("dashscope", api_key)
if resolved_api_key is None:
raise ValueError(missing_dashscope_family_key_message("dashscope"))
return {**headers, "Authorization": f"Bearer {resolved_api_key}", "Content-Type": "application/json"}
@override
def get_complete_url(
self,
model: str,
api_base: str | None,
litellm_params: dict[str, object], # mutable-ok: mirrors override contract
) -> str:
configured: Final = api_base or get_secret_str("DASHSCOPE_API_BASE")
root: Final = _api_root(configured) if configured else DASHSCOPE_TTS_DEFAULT_API_BASE
return f"{root}{text_to_speech_family(model).path}"
@override
def transform_text_to_speech_request(
self,
model: str,
input: str,
voice: str | None,
optional_params: dict[str, object], # mutable-ok: mirrors override contract
litellm_params: dict[str, object], # mutable-ok: mirrors override contract
headers: dict[str, str], # mutable-ok: mirrors override contract
) -> TextToSpeechRequestData:
voice_field: Final = {"voice": voice} if voice is not None else {}
return {"dict_body": {"model": model, "input": {"text": input, **voice_field, **optional_params}}}
@override
def transform_text_to_speech_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: "LiteLLMLoggingObj",
) -> HttpxBinaryResponseContent:
audio_url, usage = _audio_url(raw_response)
try:
audio_response: Final = safe_get(self._sync_client(), audio_url)
except SSRFError as e:
raise _refused_audio_url(e) from e
return _binary_audio(audio_response, usage)
@override
async def async_transform_text_to_speech_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: "LiteLLMLoggingObj",
) -> HttpxBinaryResponseContent:
audio_url, usage = _audio_url(raw_response)
try:
audio_response: Final = await async_safe_get(self._async_client(), audio_url)
except SSRFError as e:
raise _refused_audio_url(e) from e
return _binary_audio(audio_response, usage)
@override
def get_error_class(
self,
error_message: str,
status_code: int,
headers: dict[str, str] | httpx.Headers, # mutable-ok: mirrors override contract
) -> BaseLLMException:
return DashScopeError(status_code=status_code, message=error_message, headers=httpx.Headers(headers))

View file

@ -8658,8 +8658,9 @@ def speech(
extra_headers: Final = kwargs.get("extra_headers", None)
model_info: Final = kwargs.get("model_info", None)
shared_session: Final = kwargs.get("shared_session", None)
caller_api_base: Final = api_base or base_url
model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider(
model=model, custom_llm_provider=custom_llm_provider, api_base=api_base or base_url
model=model, custom_llm_provider=custom_llm_provider, api_base=caller_api_base
)
kwargs.pop("tags", [])
@ -8714,13 +8715,13 @@ def speech(
custom_llm_provider=custom_llm_provider,
)
response: HttpxBinaryResponseContent | Coroutine[object, object, HttpxBinaryResponseContent] | None = None
if custom_llm_provider == "edenai":
litellm_params_dict["api_base"] = api_base
if custom_llm_provider in ("edenai", "dashscope") and text_to_speech_provider_config is not None:
litellm_params_dict["api_base"] = caller_api_base if custom_llm_provider == "dashscope" else api_base
response = base_llm_http_handler.text_to_speech_handler(
model=model,
input=input,
voice=voice if isinstance(voice, str) else None,
text_to_speech_provider_config=text_to_speech_provider_config or litellm.EdenAITextToSpeechConfig(),
text_to_speech_provider_config=text_to_speech_provider_config,
text_to_speech_optional_params=optional_params,
custom_llm_provider=custom_llm_provider,
litellm_params=litellm_params_dict,

View file

@ -18274,25 +18274,31 @@
"dashscope/qwen-image-2.0": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"output_cost_per_image": 0.035,
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/images/generations"
"/v1/images/generations",
"/v1/images/edits"
]
},
"dashscope/qwen-image-2.0-pro": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"output_cost_per_image": 0.075,
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/images/generations"
"/v1/images/generations",
"/v1/images/edits"
]
},
"dashscope/qwen-image-3.0": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"output_cost_per_image": 0.03,
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/images/generations"
"/v1/images/generations",
"/v1/images/edits"
]
},
"dashscope/qwen-image-3.0-pro": {
@ -18300,7 +18306,54 @@
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"supported_endpoints": [
"/v1/images/generations"
"/v1/images/generations",
"/v1/images/edits"
]
},
"dashscope/qwen3-tts-flash": {
"input_cost_per_character": 1e-05,
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"dashscope/qwen-audio-3.1-tts-flash": {
"input_cost_per_token": 2.3e-07,
"output_cost_per_token": 1.87e-06,
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"dashscope/qwen-audio-3.1-tts-plus": {
"input_cost_per_character": 3e-05,
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"dashscope/qwen-audio-3.0-tts-plus": {
"input_cost_per_character": 2e-05,
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"dashscope/qwen-audio-3.0-tts-flash": {
"input_cost_per_character": 1.5e-05,
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"dashscope/wan3.0-video": {

View file

@ -1148,9 +1148,9 @@
"text_completion": true,
"embeddings": false,
"image_generations": true,
"image_edits": false,
"image_edits": true,
"audio_transcriptions": false,
"audio_speech": false,
"audio_speech": true,
"rerank": false,
"ocr": false,
"search": false,

View file

@ -10541,19 +10541,8 @@ class ProviderConfigManager:
return None
@staticmethod
def get_provider_image_edit_config(
model: str,
provider: LlmProviders,
) -> BaseImageEditConfig | None:
if LlmProviders.OPENAI == provider:
from litellm.llms.openai.image_edit import get_openai_image_edit_config
return get_openai_image_edit_config(model=model)
elif LlmProviders.HOSTED_VLLM == provider:
from litellm.llms.hosted_vllm.image_edit import get_hosted_vllm_image_edit_config
return get_hosted_vllm_image_edit_config(model=model)
elif LlmProviders.AZURE == provider:
def _get_model_independent_image_edit_config(provider: LlmProviders) -> BaseImageEditConfig | None:
if LlmProviders.AZURE == provider:
from litellm.llms.azure.image_edit.transformation import (
AzureImageEditConfig,
)
@ -10571,6 +10560,36 @@ class ProviderConfigManager:
)
return BlackForestLabsImageEditConfig()
elif LlmProviders.LITELLM_PROXY == provider:
from litellm.llms.litellm_proxy.image_edit.transformation import (
LiteLLMProxyImageEditConfig,
)
return LiteLLMProxyImageEditConfig()
elif LlmProviders.DASHSCOPE == provider:
from litellm.llms.dashscope.image_edit.transformation import (
DashScopeImageEditConfig,
)
return DashScopeImageEditConfig()
return None
@staticmethod
def get_provider_image_edit_config(
model: str,
provider: LlmProviders,
) -> BaseImageEditConfig | None:
model_independent_config: Final = ProviderConfigManager._get_model_independent_image_edit_config(provider)
if model_independent_config is not None:
return model_independent_config
if LlmProviders.OPENAI == provider:
from litellm.llms.openai.image_edit import get_openai_image_edit_config
return get_openai_image_edit_config(model=model)
elif LlmProviders.HOSTED_VLLM == provider:
from litellm.llms.hosted_vllm.image_edit import get_hosted_vllm_image_edit_config
return get_hosted_vllm_image_edit_config(model=model)
elif LlmProviders.FAL_AI == provider:
from litellm.llms.fal_ai.image_edit import get_fal_ai_image_edit_config
@ -10583,12 +10602,6 @@ class ProviderConfigManager:
from litellm.llms.gemini.image_edit import get_gemini_image_edit_config
return get_gemini_image_edit_config(model)
elif LlmProviders.LITELLM_PROXY == provider:
from litellm.llms.litellm_proxy.image_edit.transformation import (
LiteLLMProxyImageEditConfig,
)
return LiteLLMProxyImageEditConfig()
elif LlmProviders.VERTEX_AI == provider:
from litellm.llms.vertex_ai.image_edit import (
get_vertex_ai_image_edit_config,
@ -10810,6 +10823,12 @@ class ProviderConfigManager:
return AWSPollyTextToSpeechConfig()
elif litellm.LlmProviders.EDENAI == provider:
return litellm.EdenAITextToSpeechConfig()
elif litellm.LlmProviders.DASHSCOPE == provider:
from litellm.llms.dashscope.text_to_speech.transformation import (
DashScopeTextToSpeechConfig,
)
return DashScopeTextToSpeechConfig()
return None
@staticmethod

View file

@ -18274,25 +18274,31 @@
"dashscope/qwen-image-2.0": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"output_cost_per_image": 0.035,
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/images/generations"
"/v1/images/generations",
"/v1/images/edits"
]
},
"dashscope/qwen-image-2.0-pro": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"output_cost_per_image": 0.075,
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/images/generations"
"/v1/images/generations",
"/v1/images/edits"
]
},
"dashscope/qwen-image-3.0": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"output_cost_per_image": 0.03,
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/images/generations"
"/v1/images/generations",
"/v1/images/edits"
]
},
"dashscope/qwen-image-3.0-pro": {
@ -18300,7 +18306,54 @@
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"supported_endpoints": [
"/v1/images/generations"
"/v1/images/generations",
"/v1/images/edits"
]
},
"dashscope/qwen3-tts-flash": {
"input_cost_per_character": 1e-05,
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"dashscope/qwen-audio-3.1-tts-flash": {
"input_cost_per_token": 2.3e-07,
"output_cost_per_token": 1.87e-06,
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"dashscope/qwen-audio-3.1-tts-plus": {
"input_cost_per_character": 3e-05,
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"dashscope/qwen-audio-3.0-tts-plus": {
"input_cost_per_character": 2e-05,
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"dashscope/qwen-audio-3.0-tts-flash": {
"input_cost_per_character": 1.5e-05,
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"dashscope/wan3.0-video": {

View file

@ -1148,9 +1148,9 @@
"text_completion": true,
"embeddings": false,
"image_generations": true,
"image_edits": false,
"image_edits": true,
"audio_transcriptions": false,
"audio_speech": false,
"audio_speech": true,
"rerank": false,
"ocr": false,
"search": false,

View file

@ -0,0 +1,258 @@
import base64
import json
import os
import uuid
from collections.abc import Iterator
from pathlib import Path
from typing import Final
from urllib.parse import urlsplit, urlunsplit
import pytest
import yaml
from integration._support.client import JSON_OBJECT, Gateway, gateway_from_environment, list_value, object_value
from integration._support.process import graceful_stop_seconds, owned_proxy
from integration._support.wire import Reply, Request, Wire, wire_server
from integration.providers._dashscope_video_support import deployed, json_reply, rate, settled_spend
from pydantic import JsonValue
_API_KEY: Final = "synthetic-dashscope-key"
_MULTIMODAL_PATH: Final = "/api/v1/services/aigc/multimodal-generation/generation"
_SYNTHESIZER_PATH: Final = "/api/v1/services/audio/tts/SpeechSynthesizer"
_OSS_PATH: Final = "/oss/"
_PNG: Final = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
_PNG_DATA_URI: Final = f"data:image/png;base64,{base64.b64encode(_PNG).decode()}"
_EDITED_URL: Final = "https://dashscope-result.example.com/edited.png"
_PROMPT: Final = "Turn the red circle into a blue square"
_TEXT: Final = "Hello from LiteLLM."
_WAV: Final = b"RIFF\x24\x00\x00\x00WAVEfmt " + uuid.uuid4().bytes
_MP3: Final = b"ID3\x04\x00\x00\x00\x00\x00\x00" + uuid.uuid4().bytes
_SIGNED_QUERY: Final = "Expires=1793316671&OSSAccessKeyId=LTAI5t&Signature=ab%2Bc%3D"
_QWEN_TTS: Final = "dashscope-qwen-tts"
_QWEN_AUDIO_TTS: Final = "dashscope-qwen-audio-tts"
_INPUT_TOKENS: Final = 8
_OUTPUT_TOKENS: Final = 30
_OWNED_TIMEOUT: Final = int(2 * graceful_stop_seconds() + 120)
def _edit_api(request: Request) -> Reply:
assert (request.method, request.target) == ("POST", _MULTIMODAL_PATH), (request.method, request.target)
return json_reply(
{
"output": {"choices": [{"message": {"role": "assistant", "content": [{"image": _EDITED_URL}]}}]},
"usage": {"width": 1024, "height": 1024, "image_count": 1},
}
)
def _synthesized(request: Request, file_name: str, usage: dict[str, JsonValue]) -> Reply:
audio_url: Final = f"http://{request.headers['host']}{_OSS_PATH}{file_name}?{_SIGNED_QUERY}"
return json_reply({"output": {"audio": {"url": audio_url, "data": "", "id": "audio-1"}}, "usage": usage})
def _speech_api(request: Request) -> Reply:
if request.method == "GET" and request.target.startswith(f"{_OSS_PATH}speech.wav"):
return Reply(body=_WAV, content_type="audio/wav")
if request.method == "GET" and request.target.startswith(f"{_OSS_PATH}speech.mp3"):
return Reply(body=_MP3, content_type="audio/mpeg")
if (request.method, request.target) == ("POST", _MULTIMODAL_PATH):
return _synthesized(request, "speech.wav", {"characters": 17})
assert (request.method, request.target) == ("POST", _SYNTHESIZER_PATH), (request.method, request.target)
return _synthesized(
request, "speech.mp3", {"characters": 17, "input_tokens": _INPUT_TOKENS, "output_tokens": _OUTPUT_TOKENS}
)
def _pooled_database_url(url: str) -> str:
parts: Final = urlsplit(url)
query: Final = "&".join(part for part in (parts.query, "connection_limit=5") if part)
return urlunsplit(parts._replace(query=query))
def _tts_deployment(name: str, model: str, api_base: str) -> dict[str, JsonValue]:
return {"model_name": name, "litellm_params": {"model": model, "api_base": api_base, "api_key": _API_KEY}}
def _error_message(body: bytes) -> str:
return str(object_value(JSON_OBJECT.validate_json(body)["error"])["message"])
@pytest.fixture(scope="module")
def rig() -> Iterator[Gateway]:
with gateway_from_environment() as gateway:
yield gateway
@pytest.fixture(scope="module")
def speech_wire() -> Iterator[Wire]:
with wire_server(_speech_api) as served:
yield served
@pytest.fixture(scope="module")
def speech_proxy(rig: Gateway, speech_wire: Wire, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]:
directory: Final = tmp_path_factory.mktemp("dashscope-speech")
config: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
path: Final = directory / "dashscope-speech.yaml"
path.write_text(
yaml.safe_dump(
{
**config,
"model_list": [
_tts_deployment(_QWEN_TTS, "dashscope/qwen3-tts-flash", speech_wire.url),
_tts_deployment(_QWEN_AUDIO_TTS, "dashscope/qwen-audio-3.1-tts-flash", speech_wire.url),
],
"general_settings": {
**object_value(config["general_settings"]),
"user_url_allowed_hosts": ["127.0.0.1"],
},
}
)
)
with owned_proxy(
rig, directory, {"DATABASE_URL": _pooled_database_url(os.environ["DATABASE_URL"])}, config=path
) as owned:
yield owned
def test_a_multipart_edit_reaches_dashscope_as_inline_images_and_bills_the_edited_image(gateway: Gateway) -> None:
with wire_server(_edit_api) as wire, gateway.scenario() as scenario:
model: Final = deployed(
gateway,
scenario,
model="dashscope/qwen-image-2.0",
api_base=f"{wire.url}{_MULTIMODAL_PATH}",
api_key=_API_KEY,
)
key: Final = scenario.key(models=[model])
response: Final = gateway.client.post(
"/v1/images/edits",
data={"model": model, "prompt": _PROMPT, "size": "1024x1024", "negative_prompt": "blurry"},
files=[("image[]", ("a.png", _PNG, "image/png")), ("image[]", ("b.png", _PNG, "image/png"))],
headers={"Authorization": f"Bearer {key}"},
)
assert response.status_code == 200, response.text
data: Final = list_value(JSON_OBJECT.validate_json(response.content)["data"])
assert [object_value(item)["url"] for item in data] == [_EDITED_URL]
(request,) = wire.drain()
assert request.headers["authorization"] == f"Bearer {_API_KEY}"
assert json.loads(request.body) == {
"model": "qwen-image-2.0",
"input": {
"messages": [
{
"role": "user",
"content": [{"image": _PNG_DATA_URI}, {"image": _PNG_DATA_URI}, {"text": _PROMPT}],
}
]
},
"parameters": {"size": "1024*1024", "negative_prompt": "blurry"},
}
settled_spend(key, rate("dashscope", "output_cost_per_image", model="qwen-image-2.0"))
def test_a_fourth_image_is_refused_with_a_400_before_dashscope_is_called(gateway: Gateway) -> None:
with wire_server(_edit_api) as wire, gateway.scenario() as scenario:
model: Final = deployed(
gateway,
scenario,
model="dashscope/qwen-image-2.0",
api_base=f"{wire.url}{_MULTIMODAL_PATH}",
api_key=_API_KEY,
)
response: Final = gateway.client.post(
"/v1/images/edits",
data={"model": model, "prompt": _PROMPT},
files=[("image[]", (f"{index}.png", _PNG, "image/png")) for index in range(4)],
headers={"Authorization": f"Bearer {gateway.key}"},
)
assert response.status_code == 400, response.text
assert "at most 3 input images, got 4" in _error_message(response.content)
assert wire.drain() == ()
@pytest.mark.timeout(_OWNED_TIMEOUT)
def test_qwen3_tts_returns_the_downloaded_wav_and_bills_each_input_character(
speech_proxy: Gateway, speech_wire: Wire
) -> None:
speech_wire.drain()
with speech_proxy.scenario() as scenario:
key: Final = scenario.key(models=[_QWEN_TTS])
response: Final = speech_proxy.request(
"POST", "/v1/audio/speech", {"model": _QWEN_TTS, "input": _TEXT, "voice": "Cherry"}, key=key
)
assert response.status_code == 200, response.text
assert response.content == _WAV
assert response.headers["content-type"] == "audio/wav"
synthesis, download = speech_wire.drain()
assert (synthesis.method, synthesis.target) == ("POST", _MULTIMODAL_PATH)
assert synthesis.headers["authorization"] == f"Bearer {_API_KEY}"
assert json.loads(synthesis.body) == {"model": "qwen3-tts-flash", "input": {"text": _TEXT, "voice": "Cherry"}}
assert (download.method, download.target) == ("GET", f"{_OSS_PATH}speech.wav?{_SIGNED_QUERY}")
assert "authorization" not in download.headers
settled_spend(
key, len("".join(_TEXT.split())) * rate("dashscope", "input_cost_per_character", "qwen3-tts-flash")
)
@pytest.mark.timeout(_OWNED_TIMEOUT)
def test_qwen_audio_tts_sends_format_and_instruction_returns_the_mp3_and_bills_reported_tokens(
speech_proxy: Gateway, speech_wire: Wire
) -> None:
speech_wire.drain()
model: Final = "qwen-audio-3.1-tts-flash"
with speech_proxy.scenario() as scenario:
key: Final = scenario.key(models=[_QWEN_AUDIO_TTS])
response: Final = speech_proxy.request(
"POST",
"/v1/audio/speech",
{
"model": _QWEN_AUDIO_TTS,
"input": _TEXT,
"voice": "longanhuan_v3.1",
"response_format": "mp3",
"instructions": "Speak slowly.",
},
key=key,
)
assert response.status_code == 200, response.text
assert response.content == _MP3
assert response.headers["content-type"] == "audio/mpeg"
synthesis, download = speech_wire.drain()
assert (synthesis.method, synthesis.target) == ("POST", _SYNTHESIZER_PATH)
assert json.loads(synthesis.body) == {
"model": model,
"input": {"text": _TEXT, "voice": "longanhuan_v3.1", "format": "mp3", "instruction": "Speak slowly."},
}
assert (download.method, download.target) == ("GET", f"{_OSS_PATH}speech.mp3?{_SIGNED_QUERY}")
settled_spend(
key,
_INPUT_TOKENS * rate("dashscope", "input_cost_per_token", model)
+ _OUTPUT_TOKENS * rate("dashscope", "output_cost_per_token", model),
)
@pytest.mark.timeout(_OWNED_TIMEOUT)
def test_a_format_qwen3_tts_cannot_produce_answers_400_without_reaching_dashscope(
speech_proxy: Gateway, speech_wire: Wire
) -> None:
speech_wire.drain()
refused: Final = speech_proxy.request(
"POST", "/v1/audio/speech", {"model": _QWEN_TTS, "input": _TEXT, "voice": "Cherry", "response_format": "mp3"}
)
served: Final = speech_proxy.request(
"POST", "/v1/audio/speech", {"model": _QWEN_TTS, "input": _TEXT, "voice": "Cherry", "response_format": "wav"}
)
assert refused.status_code == 400, refused.text
assert "response_format='mp3'" in _error_message(refused.content)
assert served.status_code == 200, served.text
assert [(request.method, request.target.split("?")[0]) for request in speech_wire.drain()] == [
("POST", _MULTIMODAL_PATH),
("GET", f"{_OSS_PATH}speech.wav"),
]

View file

@ -0,0 +1,221 @@
import base64
import json
from collections.abc import Callable
from typing import Final
import httpx
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
PNG: Final = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
PNG_DATA_URI: Final = f"data:image/png;base64,{base64.b64encode(PNG).decode()}"
INTL_ENDPOINT: Final = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
EDITED_URL: Final = "https://dashscope-result.example.com/edited.png"
EDITED: Final = {
"output": {"choices": [{"message": {"role": "assistant", "content": [{"image": EDITED_URL}]}}]},
"usage": {"width": 1024, "height": 1024, "image_count": 1},
}
def _recording(
seen: list[httpx.Request], reply: Callable[[httpx.Request], httpx.Response]
) -> httpx.MockTransport: # mutable-ok: test records the requests the transport receives
def respond(request: httpx.Request) -> httpx.Response:
seen.append(request)
return reply(request)
return httpx.MockTransport(respond)
def _edited(_: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=EDITED)
def _sync_client(
seen: list[httpx.Request], reply: Callable[[httpx.Request], httpx.Response] = _edited
) -> HTTPHandler: # mutable-ok: shared request log
return HTTPHandler(client=httpx.Client(transport=_recording(seen, reply)))
@pytest.fixture(autouse=True)
def _no_ambient_dashscope_base(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DASHSCOPE_API_BASE_IMAGE", raising=False)
@pytest.mark.parametrize("asynchronous", (False, True))
async def test_an_edit_posts_every_image_inline_with_provider_params_at_the_top_of_parameters(
asynchronous: bool,
) -> None:
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
arguments: Final = {
"model": "dashscope/qwen-image-2.0",
"image": [PNG, PNG],
"prompt": "Turn the circle into a square",
"size": "1024x1024",
"n": 1,
"negative_prompt": "blurry",
"prompt_extend": False,
"watermark": False,
"seed": 42,
"api_key": "sk-dashscope-test",
}
response: Final = (
await litellm.aimage_edit(**arguments, client=AsyncHTTPHandler(transport=_recording(seen, _edited)))
if asynchronous
else litellm.image_edit(**arguments, client=_sync_client(seen))
)
assert [image.url for image in response.data or []] == [EDITED_URL]
(request,) = seen
assert str(request.url) == INTL_ENDPOINT
assert request.headers["authorization"] == "Bearer sk-dashscope-test"
assert json.loads(request.content) == {
"model": "qwen-image-2.0",
"input": {
"messages": [
{
"role": "user",
"content": [
{"image": PNG_DATA_URI},
{"image": PNG_DATA_URI},
{"text": "Turn the circle into a square"},
],
}
]
},
"parameters": {
"n": 1,
"size": "1024*1024",
"negative_prompt": "blurry",
"prompt_extend": False,
"watermark": False,
"seed": 42,
},
}
@pytest.mark.parametrize(("size", "dashscope_size"), (("1792x1024", "1792*1024"), ("1328x1328", "1328*1328")))
def test_an_openai_size_reaches_dashscope_in_its_star_format(size: str, dashscope_size: str) -> None:
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
litellm.image_edit(
model="dashscope/qwen-image-2.0", image=PNG, prompt="p", size=size, api_key="k", client=_sync_client(seen)
)
assert json.loads(seen[0].content)["parameters"] == {"size": dashscope_size}
def test_a_chat_shaped_api_base_falls_back_to_the_image_endpoint_like_image_generation() -> None:
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
litellm.image_edit(
model="dashscope/qwen-image-2.0",
image=PNG,
prompt="p",
api_key="k",
api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
client=_sync_client(seen),
)
assert str(seen[0].url) == INTL_ENDPOINT
def test_a_fourth_image_is_refused_with_a_400_before_any_request_even_under_drop_params() -> None:
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
with pytest.raises(litellm.BadRequestError) as refused:
litellm.image_edit(
model="dashscope/qwen-image-2.0",
image=[PNG, PNG, PNG, PNG],
prompt="p",
api_key="k",
drop_params=True,
client=_sync_client(seen),
)
assert refused.value.status_code == 400
assert "at most 3 input images, got 4" in str(refused.value)
assert seen == []
def test_three_images_are_accepted() -> None:
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
litellm.image_edit(
model="dashscope/qwen-image-2.0", image=[PNG, PNG, PNG], prompt="p", api_key="k", client=_sync_client(seen)
)
content: Final = json.loads(seen[0].content)["input"]["messages"][0]["content"]
assert content == [{"image": PNG_DATA_URI}] * 3 + [{"text": "p"}]
def test_an_unsupported_openai_param_answers_400_and_is_dropped_under_drop_params() -> None:
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
client: Final = _sync_client(seen)
with pytest.raises(litellm.UnsupportedParamsError) as refused:
litellm.image_edit(
model="dashscope/qwen-image-2.0", image=PNG, prompt="p", quality="high", api_key="k", client=client
)
litellm.image_edit(
model="dashscope/qwen-image-2.0",
image=PNG,
prompt="p",
quality="high",
api_key="k",
drop_params=True,
client=client,
)
assert refused.value.status_code == 400
assert "quality" in str(refused.value)
(request,) = seen
assert json.loads(request.content)["parameters"] == {}
@pytest.mark.parametrize(
("reply", "error", "message"),
(
(
httpx.Response(200, json={"code": "InvalidParameter", "message": "Image is too small"}),
litellm.APIError,
"Image is too small",
),
(
httpx.Response(400, json={"code": "InvalidParameter", "message": "Model not exist."}),
litellm.BadRequestError,
"Model not exist.",
),
),
)
def test_a_dashscope_rejection_reaches_the_caller_with_its_message(
reply: httpx.Response, error: type[Exception], message: str
) -> None:
with pytest.raises(error, match=message):
litellm.image_edit(
model="dashscope/qwen-image-2.0",
image=PNG,
prompt="p",
api_key="k",
client=_sync_client([], lambda _: reply),
)
@pytest.mark.parametrize("model", ("qwen-image-2.0", "qwen-image-2.0-pro", "qwen-image-3.0"))
@pytest.mark.usefixtures("local_model_cost_map")
def test_each_edited_image_is_billed_at_the_model_output_image_price(model: str) -> None:
response: Final = litellm.ImageResponse(data=[{"url": EDITED_URL}, {"url": EDITED_URL}])
cost: Final = litellm.completion_cost(
completion_response=response,
model=f"dashscope/{model}",
call_type="aimage_edit",
custom_llm_provider="dashscope",
)
assert cost == pytest.approx(2 * litellm.model_cost[f"dashscope/{model}"]["output_cost_per_image"])
assert cost > 0

View file

@ -0,0 +1,329 @@
import datetime
import json
from collections.abc import Callable
from typing import Final
import httpx
import pytest
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.llms.dashscope.common_utils import DashScopeError
from litellm.llms.dashscope.text_to_speech.transformation import DashScopeTextToSpeechConfig
from litellm.types.llms.openai import HttpxBinaryResponseContent
from litellm.types.utils import Usage
INTL: Final = "https://dashscope-intl.aliyuncs.com"
MULTIMODAL_PATH: Final = "/api/v1/services/aigc/multimodal-generation/generation"
SYNTHESIZER_PATH: Final = "/api/v1/services/audio/tts/SpeechSynthesizer"
AUDIO_URL: Final = (
"http://93.184.216.34/prod/qwen-tts/a1.mp3?Expires=1793316671&OSSAccessKeyId=LTAI&Signature=ab%2Bc%3D"
)
AUDIO: Final = b"ID3\x04\x00synthetic-mp3-frames"
SYNTHESIZED: Final = {
"output": {"audio": {"url": AUDIO_URL, "data": "", "id": "audio_1"}},
"usage": {"characters": 6, "input_tokens": 8, "output_tokens": 30, "total_tokens": 38},
}
Reply = Callable[[httpx.Request], httpx.Response]
def _api(reply: dict[str, object]) -> Reply: # mutable-ok: JSON body fixture
return lambda _: httpx.Response(200, json=reply)
def _audio(_: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=AUDIO, headers={"content-type": "audio/mpeg"})
def _recording(seen: list[httpx.Request], reply: Reply) -> httpx.MockTransport: # mutable-ok: request log
def respond(request: httpx.Request) -> httpx.Response:
seen.append(request)
return reply(request)
return httpx.MockTransport(respond)
def _refusing_blocking_download(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"blocking client used for {request.url} on the event loop")
def _logging() -> Logging:
return Logging(
model="dashscope-tts",
messages=[],
stream=False,
call_type="aspeech",
start_time=datetime.datetime(2026, 10, 28, tzinfo=datetime.timezone.utc),
litellm_call_id="call-tts",
function_id="fn-tts",
)
@pytest.fixture(autouse=True)
def _no_ambient_dashscope_base(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
@pytest.mark.parametrize(
("model", "optional_params", "path", "provider_input"),
(
(
"qwen3-tts-flash",
{"response_format": "wav"},
MULTIMODAL_PATH,
{"text": "Hello.", "voice": "Cherry"},
),
(
"qwen-audio-3.1-tts-flash",
{"response_format": "mp3", "instructions": "Speak slowly."},
SYNTHESIZER_PATH,
{"text": "Hello.", "voice": "Cherry", "format": "mp3", "instruction": "Speak slowly."},
),
(
"qwen-audio-3.0-tts-plus",
{},
SYNTHESIZER_PATH,
{"text": "Hello.", "voice": "Cherry"},
),
),
)
async def test_each_family_posts_to_its_endpoint_and_the_async_handler_returns_the_downloaded_audio(
model: str,
optional_params: dict[str, object], # mutable-ok: parametrized fixture
path: str,
provider_input: dict[str, object], # mutable-ok: parametrized fixture
) -> None:
api_seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
download_seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
config: Final = DashScopeTextToSpeechConfig(
sync_client=lambda: HTTPHandler(
client=httpx.Client(transport=httpx.MockTransport(_refusing_blocking_download))
),
async_client=lambda: AsyncHTTPHandler(transport=_recording(download_seen, _audio)),
)
voice, mapped = config.map_openai_params(model=model, optional_params=optional_params, voice="Cherry")
speech: Final = await BaseLLMHTTPHandler().async_text_to_speech_handler(
model=model,
input="Hello.",
voice=voice,
text_to_speech_provider_config=config,
text_to_speech_optional_params=mapped,
custom_llm_provider="dashscope",
litellm_params={"api_key": "sk-dashscope-test", "api_base": None},
logging_obj=_logging(),
timeout=30,
client=AsyncHTTPHandler(transport=_recording(api_seen, _api(SYNTHESIZED))),
)
assert speech.content == AUDIO
(api_request,) = api_seen
assert str(api_request.url) == f"{INTL}{path}"
assert api_request.headers["authorization"] == "Bearer sk-dashscope-test"
assert json.loads(api_request.content) == {"model": model, "input": provider_input}
(download,) = download_seen
assert str(download.url) == AUDIO_URL
assert "authorization" not in download.headers
def test_the_sync_handler_downloads_through_the_injected_blocking_client() -> None:
download_seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
config: Final = DashScopeTextToSpeechConfig(
sync_client=lambda: HTTPHandler(client=httpx.Client(transport=_recording(download_seen, _audio)))
)
speech: Final = BaseLLMHTTPHandler().text_to_speech_handler(
model="qwen3-tts-flash",
input="Hello.",
voice="Cherry",
text_to_speech_provider_config=config,
text_to_speech_optional_params={},
custom_llm_provider="dashscope",
litellm_params={"api_key": "k", "api_base": None},
logging_obj=_logging(),
timeout=30,
client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_api(SYNTHESIZED)))),
)
assert isinstance(speech, HttpxBinaryResponseContent)
assert speech.content == AUDIO
assert [str(request.url) for request in download_seen] == [AUDIO_URL]
@pytest.mark.parametrize(
("api_base", "environment", "model", "url"),
(
(None, None, "qwen3-tts-flash", f"{INTL}{MULTIMODAL_PATH}"),
(None, None, "qwen-audio-3.1-tts-plus", f"{INTL}{SYNTHESIZER_PATH}"),
(
"https://dashscope.aliyuncs.com/compatible-mode/v1",
None,
"qwen-audio-3.1-tts-plus",
f"https://dashscope.aliyuncs.com{SYNTHESIZER_PATH}",
),
(f"{INTL}{SYNTHESIZER_PATH}", None, "qwen3-tts-flash", f"{INTL}{MULTIMODAL_PATH}"),
(
"https://relay.example.com/dashscope/",
None,
"qwen3-tts-flash",
f"https://relay.example.com/dashscope{MULTIMODAL_PATH}",
),
(
None,
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"qwen3-tts-flash",
f"https://dashscope.aliyuncs.com{MULTIMODAL_PATH}",
),
),
)
def test_the_endpoint_follows_the_model_family_on_the_configured_host(
monkeypatch: pytest.MonkeyPatch, api_base: str | None, environment: str | None, model: str, url: str
) -> None:
if environment is not None:
monkeypatch.setenv("DASHSCOPE_API_BASE", environment)
assert DashScopeTextToSpeechConfig().get_complete_url(model=model, api_base=api_base, litellm_params={}) == url
@pytest.mark.parametrize(
("model", "optional_params", "voice", "refused"),
(
("qwen3-tts-flash", {"response_format": "mp3"}, "Cherry", "response_format='mp3'"),
("qwen3-tts-flash", {"instructions": "Whisper."}, "Cherry", "instructions='Whisper.'"),
("qwen-audio-3.1-tts-flash", {"response_format": "aac"}, "Cherry", "response_format='aac'"),
("qwen-audio-3.1-tts-flash", {"speed": 1.5}, "Cherry", "speed=1.5"),
("qwen-audio-3.1-tts-flash", {}, {"voice_id": "Cherry"}, "voice as an object"),
),
)
def test_an_unsupported_param_answers_400_and_is_dropped_under_drop_params(
model: str,
optional_params: dict[str, object], # mutable-ok: parametrized fixture
voice: str | dict[str, object], # mutable-ok: parametrized fixture
refused: str,
) -> None:
config: Final = DashScopeTextToSpeechConfig()
with pytest.raises(litellm.UnsupportedParamsError) as rejected:
config.map_openai_params(model=model, optional_params=optional_params, voice=voice)
dropped_voice, dropped = config.map_openai_params(
model=model, optional_params=optional_params, voice=voice, kwargs={"drop_params": True}
)
assert rejected.value.status_code == 400
assert refused in str(rejected.value)
assert dropped == {}
assert dropped_voice == (voice if isinstance(voice, str) else None)
async def test_aspeech_uses_the_native_endpoint_on_the_caller_api_base_and_refuses_a_private_audio_url() -> None:
api_seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
private_audio: Final = {"output": {"audio": {"url": "http://169.254.169.254/latest/meta-data"}}}
with pytest.raises(litellm.BadGatewayError) as refused:
await litellm.aspeech(
model="dashscope/qwen-audio-3.1-tts-flash",
input="Hello.",
voice="longanhuan_v3.1",
response_format="opus",
api_key="sk-dashscope-test",
api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
client=AsyncHTTPHandler(transport=_recording(api_seen, _api(private_audio))),
)
assert refused.value.status_code == 502
assert "audio URL that was refused" in str(refused.value)
(api_request,) = api_seen
assert str(api_request.url) == f"https://dashscope.aliyuncs.com{SYNTHESIZER_PATH}"
assert json.loads(api_request.content) == {
"model": "qwen-audio-3.1-tts-flash",
"input": {"text": "Hello.", "voice": "longanhuan_v3.1", "format": "opus"},
}
@pytest.mark.parametrize(
("api_reply", "audio_reply", "status", "message"),
(
(
httpx.Response(200, json={"code": "InvalidParameter", "message": "The voice property is required."}),
_audio,
400,
"The voice property is required.",
),
(
httpx.Response(200, content=b"<html>gateway</html>"),
_audio,
502,
"Unexpected DashScope text-to-speech response",
),
(
httpx.Response(200, json=SYNTHESIZED),
lambda _: httpx.Response(403),
502,
"audio download failed with HTTP 403",
),
),
)
async def test_a_failed_synthesis_or_download_raises_a_dashscope_error(
api_reply: httpx.Response, audio_reply: Reply, status: int, message: str
) -> None:
config: Final = DashScopeTextToSpeechConfig(
async_client=lambda: AsyncHTTPHandler(transport=httpx.MockTransport(audio_reply))
)
with pytest.raises(DashScopeError) as failed:
await config.async_transform_text_to_speech_response(
model="qwen3-tts-flash", raw_response=api_reply, logging_obj=_logging()
)
assert failed.value.status_code == status
assert message in failed.value.message
async def test_provider_token_usage_rides_on_the_audio_so_token_priced_speech_is_billed() -> None:
config: Final = DashScopeTextToSpeechConfig(
async_client=lambda: AsyncHTTPHandler(transport=httpx.MockTransport(_audio))
)
speech: Final = await config.async_transform_text_to_speech_response(
model="qwen-audio-3.1-tts-flash", raw_response=httpx.Response(200, json=SYNTHESIZED), logging_obj=_logging()
)
assert speech.usage == Usage(prompt_tokens=8, completion_tokens=30, total_tokens=38)
@pytest.mark.parametrize(
"model", ("qwen3-tts-flash", "qwen-audio-3.1-tts-plus", "qwen-audio-3.0-tts-plus", "qwen-audio-3.0-tts-flash")
)
@pytest.mark.usefixtures("local_model_cost_map")
def test_character_priced_speech_bills_each_input_character_at_the_model_rate(model: str) -> None:
cost: Final = litellm.completion_cost(
completion_response=HttpxBinaryResponseContent(httpx.Response(200, content=AUDIO)),
model=f"dashscope/{model}",
call_type="aspeech",
prompt="Hello.",
custom_llm_provider="dashscope",
)
assert cost == pytest.approx(6 * litellm.model_cost[f"dashscope/{model}"]["input_cost_per_character"])
assert cost > 0
@pytest.mark.usefixtures("local_model_cost_map")
def test_token_priced_speech_bills_the_provider_reported_input_and_output_tokens() -> None:
speech: Final = HttpxBinaryResponseContent(httpx.Response(200, content=AUDIO))
speech.usage = Usage(prompt_tokens=8, completion_tokens=30, total_tokens=38)
row: Final = litellm.model_cost["dashscope/qwen-audio-3.1-tts-flash"]
cost: Final = litellm.completion_cost(
completion_response=speech,
model="dashscope/qwen-audio-3.1-tts-flash",
call_type="aspeech",
prompt="Hello.",
custom_llm_provider="dashscope",
)
assert cost == pytest.approx(8 * row["input_cost_per_token"] + 30 * row["output_cost_per_token"])
assert row["output_cost_per_token"] > row["input_cost_per_token"] > 0