From e7017ce38fe78ef21c4dbf4a99ab2706f927227c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:09:48 -0700 Subject: [PATCH] fix(dashscope): bill reported speech characters and input images, follow the chat region for edits, keep the call timeout on the audio download --- litellm/cost_calculator.py | 10 ++- .../litellm_core_utils/llm_cost_calc/utils.py | 6 ++ litellm/llms/dashscope/common_utils.py | 7 +- .../dashscope/image_edit/transformation.py | 53 +++--------- .../image_generation/cost_calculator.py | 19 +++++ .../image_generation/transformation.py | 84 ++++++++++--------- .../text_to_speech/transformation.py | 33 ++++++-- ...odel_prices_and_context_window_backup.json | 1 + model_prices_and_context_window.json | 1 + ...st_dashscope_image_edit_and_speech_wire.py | 11 +-- ...est_dashscope_image_edit_transformation.py | 46 +++++++++- ...dashscope_text_to_speech_transformation.py | 44 ++++++++-- .../test_get_complete_url_trailing_slash.py | 1 + 13 files changed, 210 insertions(+), 106 deletions(-) create mode 100644 litellm/llms/dashscope/image_generation/cost_calculator.py diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 087d5b5b718..56dc875ccb0 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1846,7 +1846,10 @@ def completion_cost( video_resolution=video_resolution, ) elif call_type in _SPEECH_CALL_TYPES: - prompt_characters = litellm.utils.count_characters(text=prompt) + prompt_characters = _speech_billed_characters( + completion_response if isinstance(completion_response, HttpxBinaryResponseContent) else None, + prompt, + ) elif call_type in _TRANSCRIPTION_CALL_TYPES: # Check _hidden_params first (duration stored there to # avoid polluting the response body), then fall back to @@ -2312,6 +2315,11 @@ def response_cost_calculator( raise e +def _speech_billed_characters(speech_response: HttpxBinaryResponseContent | None, prompt: str) -> int: + reported: Final = speech_response.hidden_params.get("billed_characters") if speech_response is not None else None + return reported if isinstance(reported, int) else litellm.utils.count_characters(text=prompt) + + def _video_input_image_cost( model: str, custom_llm_provider: str | None, diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 4b0b875af6d..76a74d1969f 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -2116,6 +2116,12 @@ def _model_priced_image_cost_calculator( from litellm.llms.tencent.image_generation.transformation import is_tokenhub_image_model return tencent_image_cost_calculator if is_tokenhub_image_model(model) else None + case litellm.LlmProviders.DASHSCOPE.value: + from litellm.llms.dashscope.image_generation.cost_calculator import ( + cost_calculator as dashscope_image_cost_calculator, + ) + + return dashscope_image_cost_calculator case _: return None diff --git a/litellm/llms/dashscope/common_utils.py b/litellm/llms/dashscope/common_utils.py index 3e2e718a469..77e80dc4d63 100644 --- a/litellm/llms/dashscope/common_utils.py +++ b/litellm/llms/dashscope/common_utils.py @@ -20,9 +20,10 @@ if TYPE_CHECKING: DASHSCOPE_CHAT_COMPATIBLE_PATH: Final = "/compatible-mode/v1" DASHSCOPE_RERANK_PATH: Final = "/compatible-api/v1/reranks" +DASHSCOPE_MULTIMODAL_GENERATION_PATH: Final = "/api/v1/services/aigc/multimodal-generation/generation" -def _rerank_base_for_chat_shaped_base(api_base: str | None) -> str | None: +def dashscope_service_url_on_chat_host(api_base: str | None, service_path: str) -> str | None: if api_base is None: return None parsed: Final = urlparse(api_base) @@ -30,11 +31,11 @@ def _rerank_base_for_chat_shaped_base(api_base: str | None) -> str | None: on_aliyun_host: Final = host == "aliyuncs.com" or host.endswith(".aliyuncs.com") if not on_aliyun_host or parsed.path.rstrip("/") != DASHSCOPE_CHAT_COMPATIBLE_PATH: return None - return f"{parsed.scheme}://{parsed.netloc}{DASHSCOPE_RERANK_PATH}" + return f"{parsed.scheme}://{parsed.netloc}{service_path}" def resolve_dashscope_family_rerank_api_base(api_base: str | None, env_var: str, default_rerank_base: str) -> str: - remapped: Final = _rerank_base_for_chat_shaped_base(api_base) + remapped: Final = dashscope_service_url_on_chat_host(api_base, DASHSCOPE_RERANK_PATH) if api_base is not None and remapped is None: return api_base return get_secret_str(env_var) or remapped or default_rerank_base diff --git a/litellm/llms/dashscope/image_edit/transformation.py b/litellm/llms/dashscope/image_edit/transformation.py index e4423327eec..95fd482f7bc 100644 --- a/litellm/llms/dashscope/image_edit/transformation.py +++ b/litellm/llms/dashscope/image_edit/transformation.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import TYPE_CHECKING, Final, Protocol +from typing import TYPE_CHECKING, Final import httpx from httpx._types import RequestFiles @@ -9,18 +9,21 @@ 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.common_utils import ( + DASHSCOPE_MULTIMODAL_GENERATION_PATH, + dashscope_service_url_on_chat_host, +) from litellm.llms.dashscope.image_generation.transformation import ( OPENAI_TO_DASHSCOPE_SIZE, DashScopeImageGenerationConfig, ) +from litellm.secret_managers.main import get_secret_str 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") @@ -34,47 +37,10 @@ def _dashscope_size(size: object) -> object: 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: + def __init__(self, generation_config: DashScopeImageGenerationConfig | None = None) -> None: super().__init__() - self._generation_config: Final[DashScopeImageGeneration] = generation_config or DashScopeImageGenerationConfig() + self._generation_config: Final = generation_config or DashScopeImageGenerationConfig() @override def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: mirrors override contract @@ -119,6 +85,9 @@ class DashScopeImageEditConfig(BaseImageEditConfig): api_base: str | None, litellm_params: dict[str, object], # mutable-ok: mirrors override contract ) -> str: + regional_url: Final = dashscope_service_url_on_chat_host(api_base, DASHSCOPE_MULTIMODAL_GENERATION_PATH) + if regional_url is not None: + return get_secret_str("DASHSCOPE_API_BASE_IMAGE") or regional_url return self._generation_config.get_complete_url( api_base=api_base, api_key=None, diff --git a/litellm/llms/dashscope/image_generation/cost_calculator.py b/litellm/llms/dashscope/image_generation/cost_calculator.py new file mode 100644 index 00000000000..f699ef7e1c5 --- /dev/null +++ b/litellm/llms/dashscope/image_generation/cost_calculator.py @@ -0,0 +1,19 @@ +from typing import Final + +import litellm +from litellm.litellm_core_utils.llm_cost_calc.utils import resolve_image_model_info +from litellm.types.utils import ImageResponse, ModelInfo + + +def cost_calculator(model: str, image_response: ImageResponse, model_info: ModelInfo | None = None) -> float: + prices: Final = resolve_image_model_info( + model=model, custom_llm_provider=litellm.LlmProviders.DASHSCOPE.value, model_info=model_info + ) + output_cost_per_image: Final = prices.get("output_cost_per_image") + if output_cost_per_image is None: + raise ValueError(f"No output_cost_per_image found for dashscope/{model}") + input_images: Final = image_response.hidden_params.get("billable_input_image_count") + input_image_cost: Final = (prices.get("input_cost_per_image") or 0.0) * ( + input_images if isinstance(input_images, int) else 0 + ) + return output_cost_per_image * len(image_response.data or ()) + input_image_cost diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index eb99447e17a..80ac1030a25 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -23,7 +23,7 @@ Response format: } """ -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Iterator, Mapping from typing import TYPE_CHECKING, Any, Final import httpx @@ -66,6 +66,24 @@ OPENAI_TO_DASHSCOPE_SIZE: Final[Mapping[str, str]] = { } +def _message_image_urls(message: Mapping[str, object]) -> Iterator[str]: + contents: Final = _JSON_OBJECTS.validate_python(message.get("content", [])) + return (url for url in (item.get("image") for item in contents) if isinstance(url, str) and url) + + +def _image_urls(response_object: Mapping[str, object]) -> Iterator[str]: + output: Final = _JSON_OBJECT.validate_python(response_object.get("output", {})) + choices: Final = _JSON_OBJECTS.validate_python(output.get("choices", [])) + for choice in choices: + yield from _message_image_urls(_JSON_OBJECT.validate_python(choice.get("message", {}))) + + +def _dashscope_size(size: object) -> object: + if not isinstance(size, str): + return size + return OPENAI_TO_DASHSCOPE_SIZE.get(size, size.replace("x", "*")) + + class DashScopeImageGenerationConfig(BaseImageGenerationConfig): """ Configuration for DashScope image generation (qwen-image-2.0, qwen-image-2.0-pro, @@ -77,24 +95,16 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): def map_openai_params( self, - non_default_params: dict, - optional_params: dict, + non_default_params: dict[str, object], + optional_params: dict[str, object], model: str, drop_params: bool, - ) -> dict: + ) -> dict[str, object]: supported_params: Final = self.get_supported_openai_params(model) - mapped: Final[dict] = {} - for k, v in non_default_params.items(): - if k in optional_params: - continue - if k not in supported_params: - continue - if k == "size": - # Convert "WxH" → "W*H" - mapped["size"] = OPENAI_TO_DASHSCOPE_SIZE.get(v, v.replace("x", "*")) - else: - mapped[k] = v - return mapped + requested: Final = { + k: v for k, v in non_default_params.items() if k in supported_params and k not in optional_params + } + return {k: _dashscope_size(v) if k == "size" else v for k, v in requested.items()} def _resolve_api_key(self, api_key: str | None) -> str: resolved_api_key: Final = api_key or get_secret_str("DASHSCOPE_API_KEY") @@ -110,8 +120,8 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): api_base: str | None, api_key: str | None, model: str, - optional_params: dict, - litellm_params: dict, + optional_params: dict[str, object], + litellm_params: dict[str, object], stream: bool | None = None, ) -> str: image_api_base: Final = ( @@ -121,14 +131,14 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): def validate_environment( self, - headers: dict, + headers: dict[str, str], model: str, messages: list[AllMessageValues], - optional_params: dict, - litellm_params: dict, + optional_params: dict[str, object], + litellm_params: dict[str, object], api_key: str | None = None, api_base: str | None = None, - ) -> dict: + ) -> dict[str, str]: headers["Authorization"] = f"Bearer {self._resolve_api_key(api_key)}" headers["Content-Type"] = "application/json" return headers @@ -167,9 +177,9 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): raw_response: httpx.Response, model_response: ImageResponse, logging_obj: LiteLLMLoggingObj, - request_data: dict, - optional_params: dict, - litellm_params: dict, + request_data: dict[str, object], + optional_params: dict[str, object], + litellm_params: dict[str, object], encoding: "Tokenizer | None", api_key: str | None = None, json_mode: bool | None = None, @@ -206,17 +216,13 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): headers=raw_response.headers, ) - if not model_response.data: - model_response.data = [] - - output: Final = _JSON_OBJECT.validate_python(response_object.get("output", {})) - choices: Final = _JSON_OBJECTS.validate_python(output.get("choices", [])) - for choice in choices: - message = _JSON_OBJECT.validate_python(choice.get("message", {})) - content_list = _JSON_OBJECTS.validate_python(message.get("content", [])) - for content_item in content_list: - image_url = content_item.get("image") - if image_url: - model_response.data.append(ImageObject(url=image_url)) - - return model_response + images: Final = [*(model_response.data or ()), *(ImageObject(url=url) for url in _image_urls(response_object))] + image_response: Final = model_response.model_copy(update={"data": images}) + usage: Final = _JSON_OBJECT.validate_python(response_object.get("usage", {})) + input_image_count: Final = usage.get("input_image_count") + if isinstance(input_image_count, int) and input_image_count > 0: + image_response.hidden_params = { + **model_response.hidden_params, + "billable_input_image_count": input_image_count, + } + return image_response diff --git a/litellm/llms/dashscope/text_to_speech/transformation.py b/litellm/llms/dashscope/text_to_speech/transformation.py index c4d652c618e..2cf998c6437 100644 --- a/litellm/llms/dashscope/text_to_speech/transformation.py +++ b/litellm/llms/dashscope/text_to_speech/transformation.py @@ -4,7 +4,7 @@ from itertools import chain from typing import TYPE_CHECKING, Final import httpx -from pydantic import BaseModel, ConfigDict, ValidationError +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError from typing_extensions import override import litellm @@ -24,6 +24,7 @@ from litellm.llms.custom_httpx.http_handler import ( ) from litellm.llms.dashscope.common_utils import ( DASHSCOPE_CHAT_COMPATIBLE_PATH, + DASHSCOPE_MULTIMODAL_GENERATION_PATH, DashScopeError, missing_dashscope_family_key_message, resolve_dashscope_family_api_key, @@ -38,6 +39,7 @@ if TYPE_CHECKING: DASHSCOPE_TTS_DEFAULT_API_BASE: Final = "https://dashscope-intl.aliyuncs.com" _DASHSCOPE_API_PATH: Final = "/api/v1" _SPEECH_SYNTHESIZER_MODEL_PREFIX: Final = "qwen-audio-" +_TIMEOUT_PHASES: Final = TypeAdapter(dict[str, float | None]) @dataclass(frozen=True, slots=True) @@ -49,7 +51,7 @@ class _TextToSpeechFamily: MULTIMODAL_GENERATION_FAMILY: Final = _TextToSpeechFamily( - path="/api/v1/services/aigc/multimodal-generation/generation", + path=DASHSCOPE_MULTIMODAL_GENERATION_PATH, response_formats=frozenset({"wav"}), sends_format=False, takes_instructions=False, @@ -76,6 +78,7 @@ class _Usage(BaseModel): model_config = ConfigDict(frozen=True) input_tokens: int | None = None output_tokens: int | None = None + characters: int | None = None class _SpeechResponse(BaseModel): @@ -129,7 +132,7 @@ def _speech_response(raw_response: httpx.Response) -> _SpeechResponse: ) from e -def _audio_url(raw_response: httpx.Response) -> tuple[str, Usage | None]: +def _audio_url(raw_response: httpx.Response) -> tuple[str, _Usage | None]: parsed: Final = _speech_response(raw_response) if parsed.output is None: raise DashScopeError( @@ -137,7 +140,17 @@ def _audio_url(raw_response: httpx.Response) -> tuple[str, Usage | None]: message=parsed.message or parsed.code or raw_response.text, headers=raw_response.headers, ) - return parsed.output.audio.url, _token_usage(parsed.usage) + return parsed.output.audio.url, parsed.usage + + +def _request_timeout(raw_response: httpx.Response) -> httpx.Timeout | None: + extension: Final = raw_response.request.extensions.get("timeout") + if extension is None: + return None + phases: Final = _TIMEOUT_PHASES.validate_python(extension) + return httpx.Timeout( + connect=phases.get("connect"), read=phases.get("read"), write=phases.get("write"), pool=phases.get("pool") + ) def _token_usage(usage: _Usage | None) -> Usage | None: @@ -154,7 +167,7 @@ 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: +def _binary_audio(audio_response: httpx.Response, usage: _Usage | None) -> HttpxBinaryResponseContent: if audio_response.status_code != 200: raise DashScopeError( status_code=502, @@ -162,7 +175,9 @@ def _binary_audio(audio_response: httpx.Response, usage: Usage | None) -> HttpxB headers=audio_response.headers, ) binary: Final = HttpxBinaryResponseContent(audio_response) - binary.usage = usage + binary.usage = _token_usage(usage) + if usage is not None and usage.characters is not None: + binary.hidden_params = {**binary.hidden_params, "billed_characters": usage.characters} return binary @@ -251,7 +266,7 @@ class DashScopeTextToSpeechConfig(BaseTextToSpeechConfig): ) -> HttpxBinaryResponseContent: audio_url, usage = _audio_url(raw_response) try: - audio_response: Final = safe_get(self._sync_client(), audio_url) + audio_response: Final = safe_get(self._sync_client(), audio_url, timeout=_request_timeout(raw_response)) except SSRFError as e: raise _refused_audio_url(e) from e return _binary_audio(audio_response, usage) @@ -265,7 +280,9 @@ class DashScopeTextToSpeechConfig(BaseTextToSpeechConfig): ) -> HttpxBinaryResponseContent: audio_url, usage = _audio_url(raw_response) try: - audio_response: Final = await async_safe_get(self._async_client(), audio_url) + audio_response: Final = await async_safe_get( + self._async_client(), audio_url, timeout=_request_timeout(raw_response) + ) except SSRFError as e: raise _refused_audio_url(e) from e return _binary_audio(audio_response, usage) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 039fbf3eea6..4b2ed6df0d0 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -18298,6 +18298,7 @@ ] }, "dashscope/qwen-image-3.0": { + "input_cost_per_image": 0.003, "litellm_provider": "dashscope", "mode": "image_generation", "output_cost_per_image": 0.03, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 039fbf3eea6..4b2ed6df0d0 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -18298,6 +18298,7 @@ ] }, "dashscope/qwen-image-3.0": { + "input_cost_per_image": 0.003, "litellm_provider": "dashscope", "mode": "image_generation", "output_cost_per_image": 0.03, diff --git a/tests/integration/providers/test_dashscope_image_edit_and_speech_wire.py b/tests/integration/providers/test_dashscope_image_edit_and_speech_wire.py index a907ef51768..ea16633d81d 100644 --- a/tests/integration/providers/test_dashscope_image_edit_and_speech_wire.py +++ b/tests/integration/providers/test_dashscope_image_edit_and_speech_wire.py @@ -26,6 +26,7 @@ _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." +_BILLED_CHARACTERS: Final = len(_TEXT) _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" @@ -57,10 +58,12 @@ def _speech_api(request: Request) -> Reply: 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}) + return _synthesized(request, "speech.wav", {"characters": _BILLED_CHARACTERS}) 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} + request, + "speech.mp3", + {"characters": _BILLED_CHARACTERS, "input_tokens": _INPUT_TOKENS, "output_tokens": _OUTPUT_TOKENS}, ) @@ -194,9 +197,7 @@ def test_qwen3_tts_returns_the_downloaded_wav_and_bills_each_input_character( 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") - ) + settled_spend(key, _BILLED_CHARACTERS * rate("dashscope", "input_cost_per_character", "qwen3-tts-flash")) @pytest.mark.timeout(_OWNED_TIMEOUT) diff --git a/tests/unit/llms/dashscope/test_dashscope_image_edit_transformation.py b/tests/unit/llms/dashscope/test_dashscope_image_edit_transformation.py index fb8394e52aa..720096d5abc 100644 --- a/tests/unit/llms/dashscope/test_dashscope_image_edit_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_image_edit_transformation.py @@ -109,7 +109,28 @@ def test_an_openai_size_reaches_dashscope_in_its_star_format(size: str, dashscop 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: +@pytest.mark.parametrize( + ("api_base", "image_base", "endpoint"), + ( + ( + "https://dashscope.aliyuncs.com/compatible-mode/v1", + None, + "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + ), + ("https://dashscope-intl.aliyuncs.com/compatible-mode/v1/", None, INTL_ENDPOINT), + ("https://gw.example.com/compatible-mode/v1", None, INTL_ENDPOINT), + ( + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "https://images.example.com/edit", + "https://images.example.com/edit", + ), + ), +) +def test_a_chat_api_base_sends_the_edit_to_the_image_endpoint_in_its_region( + monkeypatch: pytest.MonkeyPatch, api_base: str, image_base: str | None, endpoint: str +) -> None: + if image_base is not None: + monkeypatch.setenv("DASHSCOPE_API_BASE_IMAGE", image_base) seen: Final[list[httpx.Request]] = [] # mutable-ok: request log litellm.image_edit( @@ -117,11 +138,11 @@ def test_a_chat_shaped_api_base_falls_back_to_the_image_endpoint_like_image_gene image=PNG, prompt="p", api_key="k", - api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", + api_base=api_base, client=_sync_client(seen), ) - assert str(seen[0].url) == INTL_ENDPOINT + assert str(seen[0].url) == endpoint def test_a_fourth_image_is_refused_with_a_400_before_any_request_even_under_drop_params() -> None: @@ -219,3 +240,22 @@ def test_each_edited_image_is_billed_at_the_model_output_image_price(model: str) assert cost == pytest.approx(2 * litellm.model_cost[f"dashscope/{model}"]["output_cost_per_image"]) assert cost > 0 + + +@pytest.mark.usefixtures("local_model_cost_map") +def test_the_input_images_dashscope_reports_are_billed_on_top_of_the_edited_images() -> None: + def reply(_: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={**EDITED, "usage": {"input_image_count": 2, "output_image_count": 1}}) + + response: Final = litellm.image_edit( + model="dashscope/qwen-image-3.0", image=[PNG, PNG], prompt="p", api_key="k", client=_sync_client([], reply) + ) + + prices: Final = litellm.model_cost["dashscope/qwen-image-3.0"] + assert prices["input_cost_per_image"] > 0 + assert litellm.completion_cost( + completion_response=response, + model="dashscope/qwen-image-3.0", + call_type="aimage_edit", + custom_llm_provider="dashscope", + ) == pytest.approx(2 * prices["input_cost_per_image"] + prices["output_cost_per_image"]) diff --git a/tests/unit/llms/dashscope/test_dashscope_text_to_speech_transformation.py b/tests/unit/llms/dashscope/test_dashscope_text_to_speech_transformation.py index 21d03ccc3e2..6bfe1654079 100644 --- a/tests/unit/llms/dashscope/test_dashscope_text_to_speech_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_text_to_speech_transformation.py @@ -27,6 +27,8 @@ SYNTHESIZED: Final = { "usage": {"characters": 6, "input_tokens": 8, "output_tokens": 30, "total_tokens": 38}, } +API_REQUEST: Final = httpx.Request("POST", f"{INTL}{MULTIMODAL_PATH}") + Reply = Callable[[httpx.Request], httpx.Response] @@ -127,9 +129,11 @@ async def test_each_family_posts_to_its_endpoint_and_the_async_handler_returns_t (download,) = download_seen assert str(download.url) == AUDIO_URL assert "authorization" not in download.headers + assert download.extensions["timeout"] == api_request.extensions["timeout"] -def test_the_sync_handler_downloads_through_the_injected_blocking_client() -> None: +def test_the_sync_handler_downloads_through_the_injected_blocking_client_within_the_call_timeout() -> 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=_recording(download_seen, _audio))) @@ -145,12 +149,15 @@ def test_the_sync_handler_downloads_through_the_injected_blocking_client() -> No litellm_params={"api_key": "k", "api_base": None}, logging_obj=_logging(), timeout=30, - client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_api(SYNTHESIZED)))), + client=HTTPHandler(client=httpx.Client(transport=_recording(api_seen, _api(SYNTHESIZED)))), ) assert isinstance(speech, HttpxBinaryResponseContent) assert speech.content == AUDIO - assert [str(request.url) for request in download_seen] == [AUDIO_URL] + (api_request,) = api_seen + (download,) = download_seen + assert str(download.url) == AUDIO_URL + assert download.extensions["timeout"] == api_request.extensions["timeout"] @pytest.mark.parametrize( @@ -259,7 +266,7 @@ async def test_aspeech_uses_the_native_endpoint_on_the_caller_api_base_and_refus "Unexpected DashScope text-to-speech response", ), ( - httpx.Response(200, json=SYNTHESIZED), + httpx.Response(200, json=SYNTHESIZED, request=API_REQUEST), lambda _: httpx.Response(403), 502, "audio download failed with HTTP 403", @@ -288,12 +295,39 @@ async def test_provider_token_usage_rides_on_the_audio_so_token_priced_speech_is ) 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() + model="qwen-audio-3.1-tts-flash", + raw_response=httpx.Response(200, json=SYNTHESIZED, request=API_REQUEST), + logging_obj=_logging(), ) assert speech.usage == Usage(prompt_tokens=8, completion_tokens=30, total_tokens=38) +@pytest.mark.usefixtures("local_model_cost_map") +async def test_character_priced_speech_bills_the_characters_dashscope_reports() -> None: + text: Final = "你好 世界。" + reported: Final = {**SYNTHESIZED, "usage": {"characters": 10}} + config: Final = DashScopeTextToSpeechConfig( + async_client=lambda: AsyncHTTPHandler(transport=httpx.MockTransport(_audio)) + ) + speech: Final = await config.async_transform_text_to_speech_response( + model="qwen3-tts-flash", + raw_response=httpx.Response(200, json=reported, request=API_REQUEST), + logging_obj=_logging(), + ) + + cost: Final = litellm.completion_cost( + completion_response=speech, + model="dashscope/qwen3-tts-flash", + call_type="aspeech", + prompt=text, + custom_llm_provider="dashscope", + ) + + assert litellm.utils.count_characters(text) != 10 + assert cost == pytest.approx(10 * litellm.model_cost["dashscope/qwen3-tts-flash"]["input_cost_per_character"]) + + @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") ) diff --git a/tests/unit/llms/test_get_complete_url_trailing_slash.py b/tests/unit/llms/test_get_complete_url_trailing_slash.py index cd778c20d9b..dac81680786 100644 --- a/tests/unit/llms/test_get_complete_url_trailing_slash.py +++ b/tests/unit/llms/test_get_complete_url_trailing_slash.py @@ -137,6 +137,7 @@ API_BASE_IS_THE_FULL_URL: Final[Mapping[str, str]] = MappingProxyType( "brave.search.transformation.BraveSearchConfig", "cohere.chat.v2_transformation.CohereV2ChatConfig", "cohere.embed.transformation.CohereEmbeddingConfig", + "dashscope.image_edit.transformation.DashScopeImageEditConfig", "dashscope.image_generation.transformation.DashScopeImageGenerationConfig", "dataforseo.search.transformation.DataForSEOSearchConfig", "datarobot.chat.transformation.DataRobotConfig",