mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
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:
parent
1ebf2a78a9
commit
b5f3ea6e06
10 changed files with 710 additions and 0 deletions
9
litellm/llms/dashscope/image_edit/__init__.py
Normal file
9
litellm/llms/dashscope/image_edit/__init__.py
Normal 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()
|
||||
194
litellm/llms/dashscope/image_edit/transformation.py
Normal file
194
litellm/llms/dashscope/image_edit/transformation.py
Normal 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.")
|
||||
3
litellm/llms/dashscope/text_to_speech/__init__.py
Normal file
3
litellm/llms/dashscope/text_to_speech/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .transformation import DashScopeTextToSpeechConfig
|
||||
|
||||
__all__ = ["DashScopeTextToSpeechConfig"]
|
||||
188
litellm/llms/dashscope/text_to_speech/transformation.py
Normal file
188
litellm/llms/dashscope/text_to_speech/transformation.py
Normal 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,
|
||||
)
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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()
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue