Spaces:
Paused
Paused
| # -*- 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", | |
| ) | |
| 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) | |
| 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 | |
| # ------------------------------------------------------------------ | |
| 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/*.", | |
| ) | |