Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -1884,6 +1884,20 @@
"timeout": 60,
"proxy": "",
},
"DashScope Embedding": {
"id": "dashscope_embedding",
"type": "dashscope_embedding",
"provider": "dashscope",
"provider_type": "embedding",
"hint": "provider_group.provider.dashscope_embedding.hint",
"enable": True,
"embedding_api_key": "",
"embedding_api_base": "https://dashscope.aliyuncs.com/api/v1",
"embedding_model": "text-embedding-v4",
"embedding_dimensions": 1024,
"timeout": 60,
"proxy": "",
},
"vLLM Rerank": {
"id": "vllm_rerank",
"type": "vllm_rerank",
Expand Down
4 changes: 4 additions & 0 deletions astrbot/core/provider/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,10 @@ def dynamic_import_provider(self, type: str) -> None:
from .sources.ollama_embedding_source import (
OllamaEmbeddingProvider as OllamaEmbeddingProvider,
)
case "dashscope_embedding":
from .sources.dashscope_embedding_source import (
DashScopeEmbeddingProvider as DashScopeEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
Expand Down
139 changes: 139 additions & 0 deletions astrbot/core/provider/sources/dashscope_embedding_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import asyncio
import os
from http import HTTPStatus
Comment on lines +1 to +3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

为了支持多线程并发请求时的线程同步,我们需要导入 threading 模块。

Suggested change
import asyncio
import os
from http import HTTPStatus
import asyncio
import os
import threading
from http import HTTPStatus


from dashscope import MultiModalEmbedding, TextEmbedding

from astrbot import logger

from ..entities import ProviderType
from ..provider import EmbeddingProvider
from ..register import register_provider_adapter

_DEFAULT_API_BASE = "https://dashscope.aliyuncs.com/api/v1"
_DEFAULT_MODEL = "text-embedding-v4"

Comment on lines +13 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

定义一个模块级别的锁 _lock,用于保护对全局变量 dashscope.base_http_api_url 的并发修改。

Suggested change
_DEFAULT_API_BASE = "https://dashscope.aliyuncs.com/api/v1"
_DEFAULT_MODEL = "text-embedding-v4"
_DEFAULT_API_BASE = "https://dashscope.aliyuncs.com/api/v1"
_DEFAULT_MODEL = "text-embedding-v4"
_lock = threading.Lock()


@register_provider_adapter(
"dashscope_embedding",
"阿里云百炼(DashScope) Embedding 提供商适配器",
provider_type=ProviderType.EMBEDDING,
)
class DashScopeEmbeddingProvider(EmbeddingProvider):
"""DashScope (Aliyun Bailian) embedding provider via the native protocol.

Routes text embedding models (text-embedding-*) through TextEmbedding and
multimodal embedding models (qwen*-vl-embedding, multimodal-embedding-*,
tongyi-embedding-vision-*) through MultiModalEmbedding, so that models
unavailable in the OpenAI-compatible mode can be used.
"""

def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.provider_config = provider_config

self.api_key = provider_config.get("embedding_api_key") or os.getenv(
"DASHSCOPE_API_KEY", ""
)
if not self.api_key:
raise ValueError("阿里云百炼(DashScope) Embedding API Key 不能为空。")

self.base_url = provider_config.get("embedding_api_base", _DEFAULT_API_BASE)
self.model = provider_config.get("embedding_model", _DEFAULT_MODEL)

provider_id = provider_config.get("id", "unknown_id")
logger.info(
f"[DashScope Embedding] {provider_id} Initialized via native SDK, "
f"base_url={self.base_url}, model={self.model}"
)
self.set_model(self.model)
Comment on lines +31 to +49

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了避免在 get_embeddingsget_dim 中重复解析 embedding_dimensions 并打印警告日志,建议在 __init__ 初始化时解析一次并将其存储在 self.dimensions 中。

    def __init__(self, provider_config: dict, provider_settings: dict) -> None:
        super().__init__(provider_config, provider_settings)
        self.provider_config = provider_config

        self.api_key = provider_config.get("embedding_api_key") or os.getenv(
            "DASHSCOPE_API_KEY", ""
        )
        if not self.api_key:
            raise ValueError("阿里云百炼(DashScope) Embedding API Key 不能为空。")

        self.base_url = provider_config.get("embedding_api_base", _DEFAULT_API_BASE)
        self.model = provider_config.get("embedding_model", _DEFAULT_MODEL)

        self.dimensions = None
        if "embedding_dimensions" in provider_config:
            try:
                dimensions = int(provider_config["embedding_dimensions"])
                if dimensions > 0:
                    self.dimensions = dimensions
            except (ValueError, TypeError):
                logger.warning(
                    f"embedding_dimensions in embedding configs is not a valid integer: "
                    f"'{provider_config['embedding_dimensions']}', ignored."
                )

        provider_id = provider_config.get("id", "unknown_id")
        logger.info(
            f"[DashScope Embedding] {provider_id} Initialized via native SDK, "
            f"base_url={self.base_url}, model={self.model}"
        )
        self.set_model(self.model)


async def get_embedding(self, text: str) -> list[float]:
"""Get the embedding vector for a single text."""
embeddings = await self.get_embeddings([text])
return embeddings[0] if embeddings else []

async def get_embeddings(self, text: list[str]) -> list[list[float]]:
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
"""Get the embedding vectors for a batch of texts via the dashscope SDK.

Multimodal models (e.g. qwen3-vl-embedding) use the
multimodal-embedding endpoint and accept text wrapped in content dicts;
text models use the text-embedding endpoint directly.
"""
if not text:
return []

is_multimodal = (
"vl-embedding" in self.model
or self.model.startswith("multimodal-embedding")
or self.model.startswith("tongyi-embedding-vision")
)

kwargs: dict = {"base_address": self.base_url}
if "embedding_dimensions" in self.provider_config:
try:
dimensions = int(self.provider_config["embedding_dimensions"])
if dimensions > 0:
kwargs["dimension"] = dimensions
except (ValueError, TypeError):
logger.warning(
f"embedding_dimensions in embedding configs is not a valid integer: "
f"'{self.provider_config['embedding_dimensions']}', ignored."
)

# The dashscope SDK is synchronous; run it in a worker thread.
# base_address is passed per-call to avoid racing on the module-level
# dashscope.base_http_api_url global under concurrent usage.
def _call():
if is_multimodal:
return MultiModalEmbedding.call(
model=self.model,
input=[{"text": t} for t in text],
api_key=self.api_key,
**kwargs,
)
return TextEmbedding.call(
model=self.model,
input=text,
api_key=self.api_key,
**kwargs,
)
Comment on lines +87 to +100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

由于 dashscope.base_http_api_url 是一个全局模块级变量,而 _call 是通过 asyncio.to_thread 在单独的线程中并发执行的,因此并发请求可能会相互覆盖该全局变量,导致请求被发送到错误的 Endpoint。建议使用 _lock 锁来确保设置全局变量和执行 API 调用这两个步骤的原子性。

Suggested change
def _call():
dashscope.base_http_api_url = self.base_url
if is_multimodal:
return MultiModalEmbedding.call(
model=self.model,
input=[{"text": t} for t in text],
api_key=self.api_key,
**kwargs,
)
return TextEmbedding.call(
model=self.model,
input=text,
api_key=self.api_key,
**kwargs,
)
def _call():
with _lock:
dashscope.base_http_api_url = self.base_url
if is_multimodal:
return MultiModalEmbedding.call(
model=self.model,
input=[{"text": t} for t in text],
api_key=self.api_key,
**kwargs,
)
return TextEmbedding.call(
model=self.model,
input=text,
api_key=self.api_key,
**kwargs,
)
References
  1. In a single-threaded asyncio event loop, synchronous functions are executed atomically and safe from race conditions. However, since _call is executed in a separate thread via asyncio.to_thread, it is not safe from race conditions when modifying shared global state like dashscope.base_http_api_url.


resp = await asyncio.to_thread(_call)

if resp.status_code != HTTPStatus.OK:
task = "multimodal-embedding" if is_multimodal else "text-embedding"
request_url = (
self.base_url.rstrip("/") + f"/services/embeddings/{task}/{task}"
)
request_id = getattr(resp, "request_id", "") or ""
raise Exception(
f"DashScope Embedding API request failed (HTTP {resp.status_code}): "
f"{resp.code or '(no code)'} - {resp.message or '(no message)'}"
f" [url={request_url}]"
+ (f" [request_id={request_id}]" if request_id else "")
)

embeddings = resp.output.get("embeddings", []) if resp.output else []
if not embeddings:
raise Exception(f"[DashScope Embedding] No embeddings returned: {resp}")

# Text embedding uses text_index; multimodal uses index.
return [
item["embedding"]
for item in sorted(
embeddings, key=lambda x: x.get("text_index", x.get("index", 0))
)
]

def get_dim(self) -> int:
"""Get the configured embedding dimension."""
if "embedding_dimensions" in self.provider_config:
try:
return int(self.provider_config["embedding_dimensions"])
except (ValueError, TypeError):
logger.warning(
f"embedding_dimensions in embedding configs is not a valid integer: "
f"'{self.provider_config['embedding_dimensions']}', ignored."
)
return 0
Comment on lines +129 to +139

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

直接返回在 __init__ 中已经解析好的 self.dimensions,避免重复解析和日志打印。

    def get_dim(self) -> int:
        """Get the configured embedding dimension."""
        return self.dimensions or 0

Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,9 @@
"gemini_embedding": {
"hint": "Gemini Embedding does not require manually adding /v1beta."
},
"dashscope_embedding": {
"hint": "Aliyun DashScope Embedding. Default base URL: https://dashscope.aliyuncs.com/api/v1."
},
"volcengine_cluster": {
"description": "Volcengine cluster",
"hint": "For voice cloning models, choose volcano_icl or volcano_icl_concurr; default is volcano_tts."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,9 @@
"gemini_embedding": {
"hint": "Gemini Embedding не требует ручного добавления /v1beta."
},
"dashscope_embedding": {
"hint": "Embedding Aliyun DashScope. URL по умолчанию: https://dashscope.aliyuncs.com/api/v1."
},
"volcengine_cluster": {
"description": "Кластер Volcengine",
"hint": "Для моделей клонирования голоса выберите volcano_icl или volcano_icl_concurr; по умолчанию volcano_tts."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1395,6 +1395,9 @@
"gemini_embedding": {
"hint": "Gemini Embedding 无需手动添加 /v1beta。"
},
"dashscope_embedding": {
"hint": "阿里云百炼 Embedding,默认地址为 https://dashscope.aliyuncs.com/api/v1。"
},
"volcengine_cluster": {
"description": "火山引擎集群",
"hint": "若使用语音复刻大模型,可选volcano_icl或volcano_icl_concurr,默认使用volcano_tts"
Expand Down
Loading
Loading