diff --git a/litellm/llms/dashscope/image_edit/__init__.py b/litellm/llms/dashscope/image_edit/__init__.py new file mode 100644 index 00000000000..413db5e335e --- /dev/null +++ b/litellm/llms/dashscope/image_edit/__init__.py @@ -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() diff --git a/litellm/llms/dashscope/image_edit/transformation.py b/litellm/llms/dashscope/image_edit/transformation.py new file mode 100644 index 00000000000..87541ca716b --- /dev/null +++ b/litellm/llms/dashscope/image_edit/transformation.py @@ -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": ""} + ] + } + ] + }, + "parameters": {"n": 1, "size": "1024*1024", ...} +} + +Response format: +{ + "output": { + "choices": [{"message": {"content": [{"image": ""}]}}] + }, + "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.") diff --git a/litellm/llms/dashscope/text_to_speech/__init__.py b/litellm/llms/dashscope/text_to_speech/__init__.py new file mode 100644 index 00000000000..35b8373b262 --- /dev/null +++ b/litellm/llms/dashscope/text_to_speech/__init__.py @@ -0,0 +1,3 @@ +from .transformation import DashScopeTextToSpeechConfig + +__all__ = ["DashScopeTextToSpeechConfig"] diff --git a/litellm/llms/dashscope/text_to_speech/transformation.py b/litellm/llms/dashscope/text_to_speech/transformation.py new file mode 100644 index 00000000000..5f95bbb5e23 --- /dev/null +++ b/litellm/llms/dashscope/text_to_speech/transformation.py @@ -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": "", "voice": ""} +} + +Non-streaming response returns an audio file URL (valid for 24h): +{ + "output": {"audio": {"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, + ) + ) diff --git a/litellm/main.py b/litellm/main.py index 6fd68921fb0..e1ea631a4eb 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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( diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index dedb9bbf40a..d900d0671a1 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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, diff --git a/litellm/utils.py b/litellm/utils.py index 0636d3683b7..72a90c754c2 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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 diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e10dde793d1..c072986b57b 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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, diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_image_edit_transformation.py b/tests/test_litellm/llms/dashscope/test_dashscope_image_edit_transformation.py new file mode 100644 index 00000000000..af042e8bd9b --- /dev/null +++ b/tests/test_litellm/llms/dashscope/test_dashscope_image_edit_transformation.py @@ -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() + ) diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_text_to_speech_transformation.py b/tests/test_litellm/llms/dashscope/test_dashscope_text_to_speech_transformation.py new file mode 100644 index 00000000000..ad7c5b9c7c5 --- /dev/null +++ b/tests/test_litellm/llms/dashscope/test_dashscope_text_to_speech_transformation.py @@ -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() + )