fix(dashscope): bill reported speech characters and input images, follow the chat region for edits, keep the call timeout on the audio download

This commit is contained in:
mateo-berri 2026-10-28 23:09:48 -07:00
parent 682683853d
commit e7017ce38f
13 changed files with 210 additions and 106 deletions

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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,

View file

@ -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)

View file

@ -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"])

View file

@ -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")
)

View file

@ -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",