Akshay66777's picture
AgentScope Gradio chat app — ZeroGPU-ready
9792ea7 verified
Raw
History Blame Contribute Delete
18.1 kB
# -*- coding: utf-8 -*-
"""The DashScope embedding model.
Handles both text-only and multimodal models under a single class.
Text models (``text-embedding-v3``, ``text-embedding-v4``) accept
``list[str | TextBlock]``. Multimodal models (``qwen*-vl-embedding``,
``multimodal-embedding-*``, ``tongyi-embedding-vision-*``)
additionally accept :class:`~agentscope.message.DataBlock`.
The model name determines which DashScope API endpoint is used.
Text payloads may be passed either as bare ``str`` or as
:class:`~agentscope.message.TextBlock` — the latter is unpacked to its
``.text`` field on entry so the rest of the pipeline only deals with
``str`` and ``DataBlock``.
"""
from __future__ import annotations
import asyncio
from datetime import datetime
from dataclasses import dataclass
from typing import Any
from .._cache_base import EmbeddingCacheBase
from .._embedding_response import EmbeddingResponse
from .._embedding_usage import EmbeddingUsage
from .._embedding_base import EmbeddingModelBase
from ..._logging import logger
from ...credential import CredentialBase
from ...message import DataBlock, Base64Source, TextBlock, URLSource
#: Model name prefixes that route to the multimodal API.
_MULTIMODAL_PREFIXES = (
"multimodal-embedding-",
"tongyi-embedding-vision-",
"qwen3-vl-embedding",
"qwen2.5-vl-embedding",
)
@dataclass
class _MultimodalLimits:
"""Per-request constraints for a multimodal embedding model."""
max_elements: int = 20
"""Maximum total content elements per API call."""
max_images: int = 5
"""Maximum image elements per API call."""
max_videos: int = 1
"""Maximum video elements per API call."""
#: Known per-model multimodal constraints (from DashScope docs).
_MODEL_LIMITS: dict[str, _MultimodalLimits] = {
"qwen3-vl-embedding": _MultimodalLimits(
max_elements=20,
max_images=5,
max_videos=1,
),
"qwen2.5-vl-embedding": _MultimodalLimits(
max_elements=20,
max_images=5,
max_videos=1,
),
"tongyi-embedding-vision-plus": _MultimodalLimits(
max_elements=20,
max_images=64,
max_videos=8,
),
"tongyi-embedding-vision-flash": _MultimodalLimits(
max_elements=20,
max_images=64,
max_videos=8,
),
"multimodal-embedding-v1": _MultimodalLimits(
max_elements=20,
max_images=1,
max_videos=1,
),
}
#: Fallback for unknown multimodal models — safest constraints.
_DEFAULT_LIMITS = _MultimodalLimits(
max_elements=20,
max_images=1,
max_videos=1,
)
class DashScopeEmbeddingModel(EmbeddingModelBase[str | TextBlock | DataBlock]):
"""Unified DashScope embedding model.
Routes to the text or multimodal DashScope API based on the model
name.
- **Text mode** (``text-embedding-*``): uses the base class's
simple batch splitting + concurrent retry.
- **Multimodal mode** (``qwen*-vl-*``, ``multimodal-*``,
``tongyi-embedding-vision-*``): overrides ``__call__`` to
perform content-aware batching that respects per-model limits
on total elements, images, and videos per request.
"""
#: Text-mode batch size (from DashScope docs: 10 for v3/v4, 25 for
#: v1/v2). Multimodal models use :data:`_MODEL_LIMITS` instead.
_TEXT_BATCH_SIZE: int = 10
def __init__(
self,
credential: CredentialBase,
model: str,
dimensions: int | None,
parameters: "DashScopeEmbeddingModel.Parameters | None" = None,
embedding_cache: EmbeddingCacheBase | None = None,
context_size: int = 8192,
max_retries: int = 3,
retry_delay: float = 1.0,
) -> None:
"""Initialize the DashScope embedding model.
Args:
credential (`CredentialBase`):
A :class:`~agentscope.credential.DashScopeCredential`
instance providing the API key.
model (`str`):
The embedding model name (e.g.
``"text-embedding-v4"`` or
``"qwen3-vl-embedding"``).
dimensions (`int | None`):
The output embedding vector dimensions. Required at
the contract level — see :class:`EmbeddingModelBase`
for the rationale. ``None`` is accepted only for
backward compatibility with legacy configs that
persisted ``dimensions`` inside ``parameters``.
parameters (`DashScopeEmbeddingModel.Parameters | None`, \
defaults to ``None``):
Provider-specific non-dimensional parameters. Currently
empty for DashScope.
embedding_cache (`EmbeddingCacheBase | None`, defaults to \
``None``):
Optional embedding cache.
context_size (`int`, defaults to ``8192``):
Maximum input tokens per text.
max_retries (`int`, defaults to ``3``):
Number of retries on transient failures.
retry_delay (`float`, defaults to ``1.0``):
Seconds between retry attempts.
"""
self._is_multimodal: bool = model.startswith(_MULTIMODAL_PREFIXES)
super().__init__(
credential=credential,
model=model,
dimensions=dimensions,
parameters=parameters,
context_size=context_size,
batch_size=self._TEXT_BATCH_SIZE,
max_retries=max_retries,
retry_delay=retry_delay,
)
self.api_key: str = credential.api_key.get_secret_value()
self.embedding_cache: EmbeddingCacheBase | None = embedding_cache
# Resolve multimodal constraints.
if self._is_multimodal:
self._limits = _MODEL_LIMITS.get(model, _DEFAULT_LIMITS)
@classmethod
def _get_retryable_exceptions(cls) -> tuple[type[Exception], ...]:
"""Return retryable exception types.
DashScope SDK does not expose typed exception classes. We
retry on ``RuntimeError``, which is raised when the API
returns a non-200 status code.
"""
return (RuntimeError,)
# ------------------------------------------------------------------
# __call__ — override for multimodal content-aware batching
# ------------------------------------------------------------------
async def __call__(
self,
inputs: list[str | TextBlock | DataBlock],
**kwargs: Any,
) -> EmbeddingResponse:
"""Embed inputs with batching and retry.
For text models, delegates to the base class (simple
``batch_size`` splitting). For multimodal models, performs
content-aware batching that respects per-model limits on
total elements, images, and videos per request.
Args:
inputs (`list[str | TextBlock | DataBlock]`):
The input data to embed. ``TextBlock`` items are
unpacked to their ``.text`` field on entry, so the
remainder of the pipeline only sees ``str`` and
``DataBlock``.
**kwargs:
Forwarded to the DashScope API.
Returns:
`EmbeddingResponse`: Merged response for all inputs.
"""
normalized: list[str | DataBlock] = [
item.text if isinstance(item, TextBlock) else item
for item in inputs
]
if not self._is_multimodal:
# Text mode — use base class batching.
return await super().__call__(normalized, **kwargs)
# Multimodal mode — content-aware batching.
batches = self._split_multimodal_batches(normalized)
if len(batches) > 1:
logger.info(
"Embedding %d multimodal inputs in %d batches for "
"model %s (limits: elements=%d, images=%d, videos=%d).",
len(normalized),
len(batches),
self.model,
self._limits.max_elements,
self._limits.max_images,
self._limits.max_videos,
)
results: list[EmbeddingResponse] = await asyncio.gather(
*(self._call_with_retry(batch, **kwargs) for batch in batches),
)
return self._merge_responses(results)
def _split_multimodal_batches(
self,
inputs: list[str | DataBlock],
) -> list[list[str | DataBlock]]:
"""Split inputs into batches that satisfy multimodal limits.
Greedy algorithm: keep adding items to the current batch
until adding the next item would violate any constraint,
then start a new batch.
Args:
inputs (`list[str | DataBlock]`):
All inputs to split.
Returns:
`list[list[str | DataBlock]]`: List of batches.
"""
limits = self._limits
batches: list[list[str | DataBlock]] = []
current_batch: list[str | DataBlock] = []
n_elements = 0
n_images = 0
n_videos = 0
for item in inputs:
# Determine what this item contributes.
is_image = False
is_video = False
if isinstance(item, DataBlock):
media_type = item.source.media_type
is_image = media_type.startswith("image/")
is_video = media_type.startswith("video/")
# Check if adding this item would exceed any limit.
would_exceed = (
n_elements + 1 > limits.max_elements
or (is_image and n_images + 1 > limits.max_images)
or (is_video and n_videos + 1 > limits.max_videos)
)
if would_exceed and current_batch:
batches.append(current_batch)
current_batch = []
n_elements = 0
n_images = 0
n_videos = 0
current_batch.append(item)
n_elements += 1
if is_image:
n_images += 1
if is_video:
n_videos += 1
if current_batch:
batches.append(current_batch)
return batches
# ------------------------------------------------------------------
# _call_api — single batch dispatch
# ------------------------------------------------------------------
async def _call_api(
self,
inputs: list[str | DataBlock],
**kwargs: Any,
) -> EmbeddingResponse:
"""Route to the text or multimodal DashScope API.
Args:
inputs (`list[str | DataBlock]`):
A single batch. For text models every element must be
``str``; for multimodal models elements may also be
:class:`~agentscope.message.DataBlock`.
**kwargs:
Forwarded to the DashScope API.
Returns:
`EmbeddingResponse`: Embedding vectors and usage info.
"""
if self._is_multimodal:
return await self._call_multimodal(inputs, **kwargs)
return await self._call_text(inputs, **kwargs)
# ------------------------------------------------------------------
# Text API
# ------------------------------------------------------------------
async def _call_text(
self,
inputs: list[str | DataBlock],
**kwargs: Any,
) -> EmbeddingResponse:
"""Call the DashScope text embedding API for a single batch.
Args:
inputs (`list[str | DataBlock]`):
Must all be ``str``; raises ``ValueError`` otherwise.
**kwargs:
Forwarded to the API.
Returns:
`EmbeddingResponse`: Embedding vectors and usage info.
"""
texts: list[str] = []
for item in inputs:
if not isinstance(item, str):
raise ValueError(
f"Text embedding model {self.model!r} only accepts "
f"str inputs, got {type(item).__name__}.",
)
texts.append(item)
api_kwargs: dict[str, Any] = {
"input": texts,
"model": self.model,
"dimension": self.dimensions,
**kwargs,
}
if self.embedding_cache:
cached = await self.embedding_cache.retrieve(
identifier=api_kwargs,
)
if cached:
return EmbeddingResponse(
embeddings=cached,
usage=EmbeddingUsage(tokens=0, time=0),
source="cache",
)
import dashscope
start_time = datetime.now()
response = dashscope.embeddings.TextEmbedding.call(
api_key=self.api_key,
**api_kwargs,
)
time = (datetime.now() - start_time).total_seconds()
if response.status_code != 200:
raise RuntimeError(
f"DashScope text embedding API error: {response}",
)
embeddings = [
entry["embedding"] for entry in response.output["embeddings"]
]
if self.embedding_cache:
await self.embedding_cache.store(
identifier=api_kwargs,
embeddings=embeddings,
)
return EmbeddingResponse(
embeddings=embeddings,
usage=EmbeddingUsage(
tokens=response.usage["total_tokens"],
time=time,
),
)
# ------------------------------------------------------------------
# Multimodal API
# ------------------------------------------------------------------
async def _call_multimodal(
self,
inputs: list[str | DataBlock],
**kwargs: Any,
) -> EmbeddingResponse:
"""Call the DashScope multimodal embedding API for a single batch.
Args:
inputs (`list[str | DataBlock]`):
``str`` for text, ``DataBlock`` for images / videos.
**kwargs:
Forwarded to the API.
Returns:
`EmbeddingResponse`: Embedding vectors and usage info.
"""
formatted: list[dict[str, str]] = []
for item in inputs:
if isinstance(item, str):
formatted.append({"text": item})
elif isinstance(item, DataBlock):
formatted.append(self._format_data_block(item))
else:
raise ValueError(
f"Invalid input: {item!r}. Expected str or DataBlock.",
)
api_kwargs: dict[str, Any] = {
"input": formatted,
"model": self.model,
"api_key": self.api_key,
**kwargs,
}
# Exclude api_key from cache identifier to avoid persisting secrets
# and to keep cache valid across key rotations.
cache_identifier = {
k: v for k, v in api_kwargs.items() if k != "api_key"
}
if self.embedding_cache:
cached = await self.embedding_cache.retrieve(
identifier=cache_identifier,
)
if cached:
return EmbeddingResponse(
embeddings=cached,
usage=EmbeddingUsage(tokens=0, time=0),
source="cache",
)
import dashscope
start_time = datetime.now()
res = dashscope.MultiModalEmbedding.call(**api_kwargs)
time = (datetime.now() - start_time).total_seconds()
if res.status_code != 200:
raise RuntimeError(
f"DashScope multimodal embedding API error: {res}",
)
embeddings = [entry["embedding"] for entry in res.output["embeddings"]]
if self.embedding_cache:
await self.embedding_cache.store(
identifier=cache_identifier,
embeddings=embeddings,
)
return EmbeddingResponse(
embeddings=embeddings,
usage=EmbeddingUsage(
tokens=res.usage.get("image_tokens", 0)
+ res.usage.get("input_tokens", 0),
time=time,
),
source="api",
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _format_data_block(block: DataBlock) -> dict[str, str]:
"""Convert a :class:`~agentscope.message.DataBlock` to the dict
format expected by the DashScope multimodal embedding API.
The ``DataBlock.source.media_type`` determines whether the
block is treated as an image or video.
Args:
block (`DataBlock`):
A data block with a ``Base64Source`` or ``URLSource``.
Returns:
`dict[str, str]`: E.g.
``{"image": "data:image/png;base64,..."}`` or
``{"video": "https://..."}``.
Raises:
`ValueError`: If the media type is unsupported or a video
block uses a non-URL source.
"""
source = block.source
media_type = source.media_type
if media_type.startswith("video/"):
if not isinstance(source, URLSource):
raise ValueError(
"Multimodal embedding API only supports URL input "
f"for video data, got {type(source).__name__}.",
)
return {"video": str(source.url)}
if media_type.startswith("image/"):
if isinstance(source, Base64Source):
return {
"image": f"data:{media_type};" f"base64,{source.data}",
}
if isinstance(source, URLSource):
return {"image": str(source.url)}
raise ValueError(
f"Unsupported media type {media_type!r} in DataBlock. "
f"Expected image/* or video/*.",
)