feat(dashscope): add qwen-image-edit-plus image edits and qwen3-tts-vc tts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mubashir Osmani 2026-07-18 22:07:16 +00:00 committed by Devin AI
parent 1ebf2a78a9
commit b5f3ea6e06
10 changed files with 710 additions and 0 deletions

View file

@ -0,0 +1,9 @@
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from .transformation import DashScopeImageEditConfig
__all__ = ["DashScopeImageEditConfig"]
def get_dashscope_image_edit_config(model: str) -> BaseImageEditConfig:
return DashScopeImageEditConfig()

View file

@ -0,0 +1,194 @@
"""
DashScope Image Edit Configuration
Handles transformation between OpenAI-compatible image edit format and the DashScope
multimodal-generation API.
API endpoint: POST https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
Request format:
{
"model": "qwen-image-edit-plus",
"input": {
"messages": [
{
"role": "user",
"content": [
{"image": "data:image/png;base64,<...>"},
{"text": "<prompt>"}
]
}
]
},
"parameters": {"n": 1, "size": "1024*1024", ...}
}
Response format:
{
"output": {
"choices": [{"message": {"content": [{"image": "<url>"}]}}]
},
"usage": {"image_count": 1, ...}
}
"""
import base64
from io import BufferedReader, BytesIO
from typing import TYPE_CHECKING, Any, cast
import httpx
from httpx._types import RequestFiles
from litellm.images.utils import ImageEditRequestUtils
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from litellm.llms.dashscope.image_generation.transformation import (
DEFAULT_API_BASE,
OPENAI_TO_DASHSCOPE_SIZE,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.images.main import ImageEditOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import FileTypes, ImageObject, ImageResponse, OpenAIImage
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
LiteLLMLoggingObj = Any
class DashScopeImageEditConfig(BaseImageEditConfig):
"""
Configuration for DashScope image editing (qwen-image-edit-plus).
"""
SUPPORTED_PARAMS: tuple[str, ...] = ("n", "size")
def get_supported_openai_params(self, model: str) -> list:
return list(self.SUPPORTED_PARAMS)
def map_openai_params(
self,
image_edit_optional_params: ImageEditOptionalRequestParams,
model: str,
drop_params: bool,
) -> dict:
return {
k: (OPENAI_TO_DASHSCOPE_SIZE.get(v, v.replace("x", "*")) if k == "size" else v)
for k, v in dict(image_edit_optional_params).items()
if v is not None and k in self.SUPPORTED_PARAMS
}
def validate_environment(
self,
headers: dict,
model: str,
api_key: str | None = None,
litellm_params: dict | None = None,
api_base: str | None = None,
) -> dict:
final_api_key = api_key or get_secret_str("DASHSCOPE_API_KEY")
if not final_api_key:
raise ValueError("DASHSCOPE_API_KEY is not set")
return {
**headers,
"Authorization": f"Bearer {final_api_key}",
"Content-Type": "application/json",
}
def use_multipart_form_data(self) -> bool:
return False
def get_complete_url(
self,
model: str,
api_base: str | None,
litellm_params: dict,
) -> str:
return api_base or get_secret_str("DASHSCOPE_API_BASE_IMAGE") or DEFAULT_API_BASE
def transform_image_edit_request(
self,
model: str,
prompt: str | None,
image: FileTypes | None,
image_edit_optional_request_params: dict,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> tuple[dict, RequestFiles]:
image_parts = self._prepare_image_parts(image) if image else []
if not image_parts:
raise ValueError("DashScope image edit requires at least one image.")
content = [*image_parts, *([{"text": prompt}] if prompt else [])]
request_body = {
"model": model,
"input": {"messages": [{"role": "user", "content": content}]},
"parameters": dict(image_edit_optional_request_params),
}
return request_body, cast(RequestFiles, []) # cast-ok: JSON provider sends no multipart files
def transform_image_edit_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> ImageResponse:
if raw_response.status_code != 200:
raise self.get_error_class(
error_message=raw_response.text,
status_code=raw_response.status_code,
headers=raw_response.headers,
)
try:
response_data = raw_response.json()
except ValueError as e:
raise self.get_error_class(
error_message=f"Failed to parse DashScope image edit response: {e}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
if "code" in response_data and "output" not in response_data:
raise self.get_error_class(
error_message=str(response_data.get("message", response_data)),
status_code=raw_response.status_code,
headers=raw_response.headers,
)
data = [
ImageObject(url=content_item["image"])
for choice in response_data.get("output", {}).get("choices", [])
for content_item in choice.get("message", {}).get("content", [])
if content_item.get("image")
]
model_response = ImageResponse()
model_response.data = cast(list[OpenAIImage], data) # cast-ok: ImageObject satisfies OpenAIImage shape
return model_response
def _prepare_image_parts(self, image: FileTypes | list[FileTypes]) -> list[dict[str, str]]:
images = image if isinstance(image, list) else [image]
return [
{
"image": "data:{};base64,{}".format(
ImageEditRequestUtils.get_image_content_type(img),
base64.b64encode(self._read_all_bytes(img)).decode("utf-8"),
)
}
for img in images
if img is not None
]
def _read_all_bytes(self, image: FileTypes) -> bytes:
if isinstance(image, bytes):
return image
if isinstance(image, (BytesIO, BufferedReader)):
current_pos = image.tell()
image.seek(0)
data = image.read()
image.seek(current_pos)
return data
raise ValueError("Unsupported image type for DashScope image edit.")

View file

@ -0,0 +1,3 @@
from .transformation import DashScopeTextToSpeechConfig
__all__ = ["DashScopeTextToSpeechConfig"]

View file

@ -0,0 +1,188 @@
"""
DashScope Text-to-Speech transformation
Maps the OpenAI TTS spec to the DashScope multimodal-generation API used by the
Qwen-TTS model family (including qwen3-tts-vc voice cloning).
API endpoint: POST https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
Request format:
{
"model": "qwen3-tts-vc",
"input": {"text": "<text>", "voice": "<voice>"}
}
Non-streaming response returns an audio file URL (valid for 24h):
{
"output": {"audio": {"url": "<url>"}},
"usage": {...}
}
Reference: https://www.alibabacloud.com/help/en/model-studio/non-realtime-tts-user-guide
"""
from typing import TYPE_CHECKING, Any
import httpx
from httpx import Headers
import litellm
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.text_to_speech.transformation import (
BaseTextToSpeechConfig,
TextToSpeechRequestData,
)
from litellm.llms.dashscope.common_utils import DashScopeError
from litellm.secret_managers.main import get_secret_str
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.llms.openai import HttpxBinaryResponseContent
else:
LiteLLMLoggingObj = Any
HttpxBinaryResponseContent = Any
DEFAULT_API_BASE = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
UNSUPPORTED_OPENAI_PARAMS = ("response_format", "speed", "instructions")
class DashScopeTextToSpeechConfig(BaseTextToSpeechConfig):
"""
Configuration for DashScope (Qwen) text-to-speech.
"""
def get_supported_openai_params(self, model: str) -> list:
return ["voice"]
def map_openai_params(
self,
model: str,
optional_params: dict,
voice: str | dict | None = None,
drop_params: bool = False,
kwargs: dict = {},
) -> tuple[str | None, dict]:
extra_body = (optional_params or {}).get("extra_body") or {}
params = {
**{
k: v
for k, v in (optional_params or {}).items()
if v is not None and k not in UNSUPPORTED_OPENAI_PARAMS and k != "extra_body"
},
**{k: v for k, v in extra_body.items() if v is not None},
}
resolved_voice = voice if isinstance(voice, str) else None
return resolved_voice, params
def validate_environment(
self,
headers: dict,
model: str,
api_key: str | None = None,
api_base: str | None = None,
) -> dict:
final_api_key = api_key or litellm.api_key or get_secret_str("DASHSCOPE_API_KEY")
if not final_api_key:
raise ValueError("DASHSCOPE_API_KEY is not set")
return {
**headers,
"Authorization": f"Bearer {final_api_key}",
"Content-Type": "application/json",
}
def get_error_class(self, error_message: str, status_code: int, headers: dict | Headers) -> BaseLLMException:
return DashScopeError(
status_code=status_code,
message=error_message,
headers=headers if isinstance(headers, Headers) else Headers(headers),
)
def get_complete_url(
self,
model: str,
api_base: str | None,
litellm_params: dict,
) -> str:
return api_base or get_secret_str("DASHSCOPE_API_BASE_TTS") or DEFAULT_API_BASE
def transform_text_to_speech_request(
self,
model: str,
input: str,
voice: str | None,
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> TextToSpeechRequestData:
tts_input = {
"text": input,
**({"voice": voice} if voice else {}),
**{k: v for k, v in (optional_params or {}).items() if v is not None},
}
return TextToSpeechRequestData(
dict_body={"model": model, "input": tts_input},
headers={"Content-Type": "application/json"},
)
def transform_text_to_speech_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> "HttpxBinaryResponseContent":
from litellm.types.llms.openai import HttpxBinaryResponseContent
if raw_response.status_code != 200:
raise self.get_error_class(
error_message=raw_response.text,
status_code=raw_response.status_code,
headers=raw_response.headers,
)
try:
response_json = raw_response.json()
except ValueError as e:
raise self.get_error_class(
error_message=f"Failed to parse DashScope TTS response: {e}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
if "code" in response_json and "output" not in response_json:
raise self.get_error_class(
error_message=str(response_json.get("message", response_json)),
status_code=raw_response.status_code,
headers=raw_response.headers,
)
audio_url = response_json.get("output", {}).get("audio", {}).get("url")
if not audio_url:
raise self.get_error_class(
error_message=f"No audio url in DashScope TTS response: {response_json}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
audio_response = httpx.get(audio_url, timeout=60.0)
if audio_response.status_code != 200:
raise self.get_error_class(
error_message=f"Failed to download DashScope audio file: {audio_response.text}",
status_code=audio_response.status_code,
headers=audio_response.headers,
)
clean_headers = {
k: v
for k, v in dict(audio_response.headers).items()
if k.lower() not in ("content-encoding", "transfer-encoding", "content-length")
}
clean_headers["content-length"] = str(len(audio_response.content))
return HttpxBinaryResponseContent(
httpx.Response(
status_code=200,
headers=clean_headers,
content=audio_response.content,
request=audio_response.request,
)
)

View file

@ -8198,6 +8198,34 @@ def speech(
api_key=api_key,
**kwargs,
)
elif custom_llm_provider == "dashscope":
from litellm.llms.dashscope.text_to_speech.transformation import (
DashScopeTextToSpeechConfig,
)
dashscope_config = text_to_speech_provider_config or DashScopeTextToSpeechConfig()
if api_base is not None:
litellm_params_dict["api_base"] = api_base
if api_key is not None:
litellm_params_dict["api_key"] = api_key
voice_str = voice if isinstance(voice, str) else None
response = base_llm_http_handler.text_to_speech_handler(
model=model,
input=input,
voice=voice_str,
text_to_speech_provider_config=dashscope_config,
text_to_speech_optional_params=optional_params,
custom_llm_provider=custom_llm_provider,
litellm_params=litellm_params_dict,
logging_obj=logging_obj,
timeout=timeout,
extra_headers=extra_headers,
client=client,
_is_async=aspeech or False,
)
if response is None:
raise Exception(

View file

@ -13237,6 +13237,23 @@
"/v1/images/generations"
]
},
"dashscope/qwen-image-edit-plus": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/qwen-image-edit-api",
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
]
},
"dashscope/qwen3-tts-vc": {
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/non-realtime-tts-user-guide",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"databricks/databricks-bge-large-en": {
"input_cost_per_token": 1.0003e-07,
"input_dbu_cost_per_token": 1.429e-06,

View file

@ -8798,6 +8798,12 @@ class ProviderConfigManager:
)
return get_openrouter_image_edit_config(model)
elif LlmProviders.DASHSCOPE == provider:
from litellm.llms.dashscope.image_edit import (
get_dashscope_image_edit_config,
)
return get_dashscope_image_edit_config(model)
return None
@staticmethod
@ -8964,6 +8970,12 @@ class ProviderConfigManager:
)
return AWSPollyTextToSpeechConfig()
elif litellm.LlmProviders.DASHSCOPE == provider:
from litellm.llms.dashscope.text_to_speech.transformation import (
DashScopeTextToSpeechConfig,
)
return DashScopeTextToSpeechConfig()
return None
@staticmethod

View file

@ -13237,6 +13237,23 @@
"/v1/images/generations"
]
},
"dashscope/qwen-image-edit-plus": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/qwen-image-edit-api",
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
]
},
"dashscope/qwen3-tts-vc": {
"litellm_provider": "dashscope",
"mode": "audio_speech",
"source": "https://www.alibabacloud.com/help/en/model-studio/non-realtime-tts-user-guide",
"supported_endpoints": [
"/v1/audio/speech"
]
},
"databricks/databricks-bge-large-en": {
"input_cost_per_token": 1.0003e-07,
"input_dbu_cost_per_token": 1.429e-06,

View file

@ -0,0 +1,138 @@
"""
Unit tests for DashScope image edit support (qwen-image-edit-plus).
"""
import base64
from unittest.mock import MagicMock
import httpx
import pytest
from litellm.llms.dashscope.image_edit.transformation import (
DashScopeImageEditConfig,
)
from litellm.llms.dashscope.image_generation.transformation import DEFAULT_API_BASE
from litellm.types.router import GenericLiteLLMParams
@pytest.fixture
def config() -> DashScopeImageEditConfig:
return DashScopeImageEditConfig()
def test_provider_routing():
from litellm.utils import ProviderConfigManager, get_llm_provider
from litellm.types.utils import LlmProviders
_, provider, _, _ = get_llm_provider("dashscope/qwen-image-edit-plus")
assert provider == "dashscope"
cfg = ProviderConfigManager.get_provider_image_edit_config(
"qwen-image-edit-plus", LlmProviders.DASHSCOPE
)
assert isinstance(cfg, DashScopeImageEditConfig)
def test_uses_json_not_multipart(config: DashScopeImageEditConfig):
assert config.use_multipart_form_data() is False
def test_get_complete_url_default(config: DashScopeImageEditConfig):
assert config.get_complete_url("qwen-image-edit-plus", None, {}) == DEFAULT_API_BASE
assert (
config.get_complete_url("qwen-image-edit-plus", "https://custom/api", {})
== "https://custom/api"
)
def test_validate_environment_requires_key(config: DashScopeImageEditConfig):
with pytest.raises(ValueError, match="DASHSCOPE_API_KEY is not set"):
config.validate_environment(headers={}, model="qwen-image-edit-plus", api_key=None)
headers = config.validate_environment(
headers={}, model="qwen-image-edit-plus", api_key="sk-test"
)
assert headers["Authorization"] == "Bearer sk-test"
def test_map_openai_params_size_and_n(config: DashScopeImageEditConfig):
mapped = config.map_openai_params(
image_edit_optional_params={"size": "1024x1024", "n": 2, "unsupported": "x"},
model="qwen-image-edit-plus",
drop_params=True,
)
assert mapped == {"size": "1024*1024", "n": 2}
def test_transform_request_embeds_image_and_prompt(config: DashScopeImageEditConfig):
image_bytes = b"\x89PNG\r\n\x1a\nfakepng"
body, files = config.transform_image_edit_request(
model="qwen-image-edit-plus",
prompt="make it snow",
image=image_bytes,
image_edit_optional_request_params={"size": "1024*1024", "n": 1},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert body["model"] == "qwen-image-edit-plus"
assert body["parameters"] == {"size": "1024*1024", "n": 1}
content = body["input"]["messages"][0]["content"]
# first item is the base64 data-url image, last item is the prompt
assert content[0]["image"].startswith("data:")
assert base64.b64encode(image_bytes).decode("utf-8") in content[0]["image"]
assert content[-1] == {"text": "make it snow"}
def test_transform_request_multiple_images(config: DashScopeImageEditConfig):
body, _ = config.transform_image_edit_request(
model="qwen-image-edit-plus",
prompt="fuse",
image=[b"imgone", b"imgtwo"],
image_edit_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
content = body["input"]["messages"][0]["content"]
image_parts = [c for c in content if "image" in c]
assert len(image_parts) == 2
def test_transform_request_requires_image(config: DashScopeImageEditConfig):
with pytest.raises(ValueError, match="requires at least one image"):
config.transform_image_edit_request(
model="qwen-image-edit-plus",
prompt="x",
image=None,
image_edit_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
def test_transform_response_parses_image_url(config: DashScopeImageEditConfig):
raw = MagicMock(spec=httpx.Response)
raw.status_code = 200
raw.json.return_value = {
"output": {
"choices": [
{"message": {"content": [{"image": "https://cdn/edited.png"}]}}
]
}
}
resp = config.transform_image_edit_response(
model="qwen-image-edit-plus", raw_response=raw, logging_obj=MagicMock()
)
assert resp.data is not None
assert resp.data[0].url == "https://cdn/edited.png"
def test_transform_response_raises_on_api_error_in_200(config: DashScopeImageEditConfig):
raw = MagicMock(spec=httpx.Response)
raw.status_code = 200
raw.headers = httpx.Headers({})
raw.json.return_value = {"code": "InvalidParameter", "message": "bad size"}
with pytest.raises(Exception, match="bad size"):
config.transform_image_edit_response(
model="qwen-image-edit-plus", raw_response=raw, logging_obj=MagicMock()
)

View file

@ -0,0 +1,104 @@
"""
Unit tests for DashScope text-to-speech support (qwen3-tts-vc).
"""
from unittest.mock import MagicMock, patch
import httpx
import pytest
from litellm.llms.dashscope.text_to_speech.transformation import (
DEFAULT_API_BASE,
DashScopeTextToSpeechConfig,
)
@pytest.fixture
def config() -> DashScopeTextToSpeechConfig:
return DashScopeTextToSpeechConfig()
def test_provider_routing():
from litellm.utils import ProviderConfigManager, get_llm_provider
from litellm.types.utils import LlmProviders
_, provider, _, _ = get_llm_provider("dashscope/qwen3-tts-vc")
assert provider == "dashscope"
cfg = ProviderConfigManager.get_provider_text_to_speech_config(
"qwen3-tts-vc", LlmProviders.DASHSCOPE
)
assert isinstance(cfg, DashScopeTextToSpeechConfig)
def test_get_complete_url_default(config: DashScopeTextToSpeechConfig):
assert config.get_complete_url("qwen3-tts-vc", None, {}) == DEFAULT_API_BASE
def test_validate_environment_requires_key(config: DashScopeTextToSpeechConfig):
with pytest.raises(ValueError, match="DASHSCOPE_API_KEY is not set"):
config.validate_environment(headers={}, model="qwen3-tts-vc", api_key=None)
headers = config.validate_environment(headers={}, model="qwen3-tts-vc", api_key="sk-x")
assert headers["Authorization"] == "Bearer sk-x"
assert headers["Content-Type"] == "application/json"
def test_map_openai_params_drops_unsupported(config: DashScopeTextToSpeechConfig):
voice, params = config.map_openai_params(
model="qwen3-tts-vc",
optional_params={"response_format": "mp3", "speed": 1.0, "extra_body": {"language_type": "English"}},
voice="my-voice",
)
assert voice == "my-voice"
assert "response_format" not in params
assert "speed" not in params
assert params["language_type"] == "English"
def test_transform_request_shape(config: DashScopeTextToSpeechConfig):
data = config.transform_text_to_speech_request(
model="qwen3-tts-vc",
input="hello world",
voice="my-voice",
optional_params={"language_type": "English"},
litellm_params={},
headers={},
)
body = data["dict_body"]
assert body["model"] == "qwen3-tts-vc"
assert body["input"]["text"] == "hello world"
assert body["input"]["voice"] == "my-voice"
assert body["input"]["language_type"] == "English"
def test_transform_response_downloads_audio(config: DashScopeTextToSpeechConfig):
raw = MagicMock(spec=httpx.Response)
raw.status_code = 200
raw.json.return_value = {"output": {"audio": {"url": "https://cdn/audio.wav"}}}
audio_resp = httpx.Response(
status_code=200,
headers={"content-type": "audio/wav"},
content=b"RIFFfakeaudio",
request=httpx.Request("GET", "https://cdn/audio.wav"),
)
with patch("httpx.get", return_value=audio_resp) as mock_get:
result = config.transform_text_to_speech_response(
model="qwen3-tts-vc", raw_response=raw, logging_obj=MagicMock()
)
mock_get.assert_called_once_with("https://cdn/audio.wav", timeout=60.0)
assert result.content == b"RIFFfakeaudio"
def test_transform_response_raises_without_audio_url(config: DashScopeTextToSpeechConfig):
raw = MagicMock(spec=httpx.Response)
raw.status_code = 200
raw.headers = httpx.Headers({})
raw.json.return_value = {"output": {}}
with pytest.raises(Exception, match="No audio url"):
config.transform_text_to_speech_response(
model="qwen3-tts-vc", raw_response=raw, logging_obj=MagicMock()
)