mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
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:
parent
682683853d
commit
e7017ce38f
13 changed files with 210 additions and 106 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
19
litellm/llms/dashscope/image_generation/cost_calculator.py
Normal file
19
litellm/llms/dashscope/image_generation/cost_calculator.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue