From 99dd28cba4a10f1a708bde788f5a73415f18e55b Mon Sep 17 00:00:00 2001 From: Fiber Date: Sat, 4 Jul 2026 16:17:54 +0800 Subject: [PATCH 1/2] feat: add DashScope embedding provider with multimodal model support --- astrbot/core/config/default.py | 14 + astrbot/core/provider/manager.py | 4 + .../sources/dashscope_embedding_source.py | 138 +++++++++ .../en-US/features/config-metadata.json | 3 + .../ru-RU/features/config-metadata.json | 3 + .../zh-CN/features/config-metadata.json | 3 + tests/test_dashscope_embedding_source.py | 273 ++++++++++++++++++ 7 files changed, 438 insertions(+) create mode 100644 astrbot/core/provider/sources/dashscope_embedding_source.py create mode 100644 tests/test_dashscope_embedding_source.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 7fb847dccd..6609d775c6 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -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", diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index ae4001fcd6..7e2d82d6cf 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -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, diff --git a/astrbot/core/provider/sources/dashscope_embedding_source.py b/astrbot/core/provider/sources/dashscope_embedding_source.py new file mode 100644 index 0000000000..f3a904678a --- /dev/null +++ b/astrbot/core/provider/sources/dashscope_embedding_source.py @@ -0,0 +1,138 @@ +import asyncio +import os +from http import HTTPStatus + +import dashscope +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" + + +@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) + + 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]]: + """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. + """ + is_multimodal = ( + "vl-embedding" in self.model + or self.model.startswith("multimodal-embedding") + or self.model.startswith("tongyi-embedding-vision") + ) + + kwargs: dict = {} + 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 and reads the API base from a + # module-level global. Set it inside the worker thread right before the + # call so this request targets the configured endpoint. + 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, + ) + + 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 diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 31d1362b26..9b3622e88c 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -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." diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index f0a1294d7c..c91e19812e 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -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." diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 6ae988383f..c2e4ee6dbc 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -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" diff --git a/tests/test_dashscope_embedding_source.py b/tests/test_dashscope_embedding_source.py new file mode 100644 index 0000000000..d9fa1707e2 --- /dev/null +++ b/tests/test_dashscope_embedding_source.py @@ -0,0 +1,273 @@ +"""Unit tests for the DashScope embedding provider.""" + +import dashscope +import pytest + +from astrbot.core.provider.sources.dashscope_embedding_source import ( + DashScopeEmbeddingProvider, +) + + +class _FakeResponse: + """Minimal stand-in for dashscope.DashScopeAPIResponse.""" + + def __init__( + self, + status_code=200, + output=None, + code="", + message="", + request_id="", + ): + self.status_code = status_code + self.output = output + self.code = code + self.message = message + self.request_id = request_id + + +def _make_provider(config: dict | None = None) -> DashScopeEmbeddingProvider: + config = config or {} + config.setdefault("embedding_api_key", "sk-test") + return DashScopeEmbeddingProvider(config, {}) + + +# --------------------------------------------------------------------------- +# __init__ +# --------------------------------------------------------------------------- + + +def test_requires_api_key(): + with pytest.raises(ValueError, match="API Key"): + DashScopeEmbeddingProvider({"embedding_api_key": ""}, {}) + + +def test_env_var_fallback(monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-from-env") + provider = DashScopeEmbeddingProvider({"embedding_api_key": ""}, {}) + assert provider.api_key == "sk-from-env" + + +def test_api_key_takes_precedence_over_env(monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-from-env") + provider = DashScopeEmbeddingProvider({"embedding_api_key": "sk-config"}, {}) + assert provider.api_key == "sk-config" + + +def test_defaults(): + provider = _make_provider() + assert provider.model == "text-embedding-v4" + assert provider.base_url == "https://dashscope.aliyuncs.com/api/v1" + + +def test_user_values_preserved(): + provider = _make_provider( + { + "embedding_model": "qwen3-vl-embedding", + "embedding_api_base": "https://dashscope-intl.aliyuncs.com/api/v1", + } + ) + assert provider.model == "qwen3-vl-embedding" + assert provider.base_url == "https://dashscope-intl.aliyuncs.com/api/v1" + + +# --------------------------------------------------------------------------- +# get_embeddings — text models +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_text_model_routes_to_text_embedding(monkeypatch): + provider = _make_provider({"embedding_dimensions": 1024}) + captured: dict = {} + + def fake_call(**kwargs): + captured["kwargs"] = kwargs + captured["base_http_api_url"] = dashscope.base_http_api_url + return _FakeResponse( + output={ + "embeddings": [ + {"embedding": [0.4, 0.5], "text_index": 1}, + {"embedding": [0.1, 0.2], "text_index": 0}, + ] + } + ) + + import astrbot.core.provider.sources.dashscope_embedding_source as mod + + monkeypatch.setattr(mod.TextEmbedding, "call", fake_call) + monkeypatch.setattr( + mod.MultiModalEmbedding, + "call", + lambda **kw: pytest.fail("should not call MultiModalEmbedding for text models"), + ) + + result = await provider.get_embeddings(["a", "b"]) + + assert result == [[0.1, 0.2], [0.4, 0.5]] + assert captured["kwargs"]["model"] == "text-embedding-v4" + assert captured["kwargs"]["input"] == ["a", "b"] + assert captured["kwargs"]["api_key"] == "sk-test" + assert captured["kwargs"]["dimension"] == 1024 + assert captured["base_http_api_url"] == ("https://dashscope.aliyuncs.com/api/v1") + + +# --------------------------------------------------------------------------- +# get_embeddings — multimodal models +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_multimodal_model_routes_to_multimodal_embedding(monkeypatch): + provider = _make_provider( + {"embedding_model": "qwen3-vl-embedding", "embedding_dimensions": 1024} + ) + captured: dict = {} + + def fake_call(**kwargs): + captured["kwargs"] = kwargs + captured["base_http_api_url"] = dashscope.base_http_api_url + # Multimodal response uses "index" instead of "text_index". + return _FakeResponse( + output={ + "embeddings": [ + {"embedding": [0.7, 0.8], "index": 1}, + {"embedding": [0.1, 0.2], "index": 0}, + ] + } + ) + + import astrbot.core.provider.sources.dashscope_embedding_source as mod + + monkeypatch.setattr(mod.MultiModalEmbedding, "call", fake_call) + monkeypatch.setattr( + mod.TextEmbedding, + "call", + lambda **kw: pytest.fail("should not call TextEmbedding for multimodal models"), + ) + + result = await provider.get_embeddings(["hello", "world"]) + + assert result == [[0.1, 0.2], [0.7, 0.8]] + assert captured["kwargs"]["model"] == "qwen3-vl-embedding" + # Multimodal input wraps each text in a content dict. + assert captured["kwargs"]["input"] == [{"text": "hello"}, {"text": "world"}] + assert captured["kwargs"]["api_key"] == "sk-test" + assert captured["kwargs"]["dimension"] == 1024 + assert captured["base_http_api_url"] == ("https://dashscope.aliyuncs.com/api/v1") + + +@pytest.mark.asyncio +async def test_tongyi_vision_model_routes_to_multimodal(monkeypatch): + """tongyi-embedding-vision-* models also use the multimodal endpoint.""" + provider = _make_provider({"embedding_model": "tongyi-embedding-vision-plus"}) + captured: dict = {} + + def fake_call(**kwargs): + captured["kwargs"] = kwargs + return _FakeResponse( + output={"embeddings": [{"embedding": [0.1, 0.2], "index": 0}]} + ) + + import astrbot.core.provider.sources.dashscope_embedding_source as mod + + monkeypatch.setattr(mod.MultiModalEmbedding, "call", fake_call) + + result = await provider.get_embeddings(["hi"]) + assert result == [[0.1, 0.2]] + + +# --------------------------------------------------------------------------- +# get_embedding (single text convenience wrapper) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_embedding_single(monkeypatch): + provider = _make_provider() + + async def fake_get_embeddings(texts): + return [[0.5, 0.6]] + + monkeypatch.setattr(provider, "get_embeddings", fake_get_embeddings) + result = await provider.get_embedding("hello") + assert result == [0.5, 0.6] + + +# --------------------------------------------------------------------------- +# error handling +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_error_surfaces_status_code_and_request_id(monkeypatch): + provider = _make_provider() + + def fake_call(**kwargs): + return _FakeResponse( + status_code=400, + code="InvalidParameter", + message="bad input", + request_id="req-123", + ) + + import astrbot.core.provider.sources.dashscope_embedding_source as mod + + monkeypatch.setattr(mod.TextEmbedding, "call", fake_call) + + with pytest.raises( + Exception, + match=r"HTTP 400.*InvalidParameter.*bad input" + r".*url=https://dashscope\.aliyuncs\.com/api/v1/services/embeddings/text-embedding/text-embedding" + r".*request_id=req-123", + ): + await provider.get_embeddings(["hi"]) + + +@pytest.mark.asyncio +async def test_multimodal_error_url_uses_multimodal_path(monkeypatch): + provider = _make_provider({"embedding_model": "qwen3-vl-embedding"}) + + def fake_call(**kwargs): + return _FakeResponse(status_code=404, code="Unkonwn", message="") + + import astrbot.core.provider.sources.dashscope_embedding_source as mod + + monkeypatch.setattr(mod.MultiModalEmbedding, "call", fake_call) + + with pytest.raises( + Exception, + match=r"HTTP 404.*url=https://dashscope\.aliyuncs\.com/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding", + ): + await provider.get_embeddings(["hi"]) + + +@pytest.mark.asyncio +async def test_no_embeddings_raises(monkeypatch): + provider = _make_provider() + monkeypatch.setattr( + "astrbot.core.provider.sources.dashscope_embedding_source.TextEmbedding.call", + lambda **kw: _FakeResponse(output={}), + ) + with pytest.raises(Exception, match="No embeddings"): + await provider.get_embeddings(["hi"]) + + +# --------------------------------------------------------------------------- +# get_dim +# --------------------------------------------------------------------------- + + +def test_get_dim_returns_configured(): + provider = _make_provider({"embedding_dimensions": 768}) + assert provider.get_dim() == 768 + + +def test_get_dim_returns_zero_when_not_set(): + provider = _make_provider() + assert provider.get_dim() == 0 + + +def test_get_dim_returns_zero_when_invalid(): + provider = _make_provider({"embedding_dimensions": "abc"}) + assert provider.get_dim() == 0 From 90442c38efd76247957a2058c38d755838c771bb Mon Sep 17 00:00:00 2001 From: Fiber Date: Sat, 4 Jul 2026 17:46:52 +0800 Subject: [PATCH 2/2] fix: use per-call base_address, handle empty input and invalid dimensions --- .../sources/dashscope_embedding_source.py | 13 +- tests/test_dashscope_embedding_source.py | 127 +++++++++++++----- 2 files changed, 101 insertions(+), 39 deletions(-) diff --git a/astrbot/core/provider/sources/dashscope_embedding_source.py b/astrbot/core/provider/sources/dashscope_embedding_source.py index f3a904678a..5502016697 100644 --- a/astrbot/core/provider/sources/dashscope_embedding_source.py +++ b/astrbot/core/provider/sources/dashscope_embedding_source.py @@ -2,7 +2,6 @@ import os from http import HTTPStatus -import dashscope from dashscope import MultiModalEmbedding, TextEmbedding from astrbot import logger @@ -61,13 +60,16 @@ async def get_embeddings(self, text: list[str]) -> list[list[float]]: 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 = {} + kwargs: dict = {"base_address": self.base_url} if "embedding_dimensions" in self.provider_config: try: dimensions = int(self.provider_config["embedding_dimensions"]) @@ -79,11 +81,10 @@ async def get_embeddings(self, text: list[str]) -> list[list[float]]: f"'{self.provider_config['embedding_dimensions']}', ignored." ) - # The dashscope SDK is synchronous and reads the API base from a - # module-level global. Set it inside the worker thread right before the - # call so this request targets the configured endpoint. + # 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(): - dashscope.base_http_api_url = self.base_url if is_multimodal: return MultiModalEmbedding.call( model=self.model, diff --git a/tests/test_dashscope_embedding_source.py b/tests/test_dashscope_embedding_source.py index d9fa1707e2..8c57724e9f 100644 --- a/tests/test_dashscope_embedding_source.py +++ b/tests/test_dashscope_embedding_source.py @@ -1,6 +1,5 @@ """Unit tests for the DashScope embedding provider.""" -import dashscope import pytest from astrbot.core.provider.sources.dashscope_embedding_source import ( @@ -32,6 +31,16 @@ def _make_provider(config: dict | None = None) -> DashScopeEmbeddingProvider: return DashScopeEmbeddingProvider(config, {}) +def _patch_sdk(monkeypatch, *, text=None, multimodal=None): + """Patch TextEmbedding.call / MultiModalEmbedding.call in the source module.""" + import astrbot.core.provider.sources.dashscope_embedding_source as mod + + if text is not None: + monkeypatch.setattr(mod.TextEmbedding, "call", text) + if multimodal is not None: + monkeypatch.setattr(mod.MultiModalEmbedding, "call", multimodal) + + # --------------------------------------------------------------------------- # __init__ # --------------------------------------------------------------------------- @@ -78,14 +87,19 @@ def test_user_values_preserved(): @pytest.mark.asyncio async def test_text_model_routes_to_text_embedding(monkeypatch): - provider = _make_provider({"embedding_dimensions": 1024}) + provider = _make_provider( + { + "embedding_api_base": "https://custom.example.com/api/v1", + "embedding_dimensions": 1024, + } + ) captured: dict = {} def fake_call(**kwargs): captured["kwargs"] = kwargs - captured["base_http_api_url"] = dashscope.base_http_api_url return _FakeResponse( output={ + # Intentionally out of order to verify text_index sorting. "embeddings": [ {"embedding": [0.4, 0.5], "text_index": 1}, {"embedding": [0.1, 0.2], "text_index": 0}, @@ -93,13 +107,12 @@ def fake_call(**kwargs): } ) - import astrbot.core.provider.sources.dashscope_embedding_source as mod - - monkeypatch.setattr(mod.TextEmbedding, "call", fake_call) - monkeypatch.setattr( - mod.MultiModalEmbedding, - "call", - lambda **kw: pytest.fail("should not call MultiModalEmbedding for text models"), + _patch_sdk( + monkeypatch, + text=fake_call, + multimodal=lambda **kw: pytest.fail( + "should not call MultiModalEmbedding for text models" + ), ) result = await provider.get_embeddings(["a", "b"]) @@ -109,7 +122,8 @@ def fake_call(**kwargs): assert captured["kwargs"]["input"] == ["a", "b"] assert captured["kwargs"]["api_key"] == "sk-test" assert captured["kwargs"]["dimension"] == 1024 - assert captured["base_http_api_url"] == ("https://dashscope.aliyuncs.com/api/v1") + # base_address is passed per-call instead of mutating the module global. + assert captured["kwargs"]["base_address"] == "https://custom.example.com/api/v1" # --------------------------------------------------------------------------- @@ -126,7 +140,6 @@ async def test_multimodal_model_routes_to_multimodal_embedding(monkeypatch): def fake_call(**kwargs): captured["kwargs"] = kwargs - captured["base_http_api_url"] = dashscope.base_http_api_url # Multimodal response uses "index" instead of "text_index". return _FakeResponse( output={ @@ -137,13 +150,12 @@ def fake_call(**kwargs): } ) - import astrbot.core.provider.sources.dashscope_embedding_source as mod - - monkeypatch.setattr(mod.MultiModalEmbedding, "call", fake_call) - monkeypatch.setattr( - mod.TextEmbedding, - "call", - lambda **kw: pytest.fail("should not call TextEmbedding for multimodal models"), + _patch_sdk( + monkeypatch, + multimodal=fake_call, + text=lambda **kw: pytest.fail( + "should not call TextEmbedding for multimodal models" + ), ) result = await provider.get_embeddings(["hello", "world"]) @@ -154,7 +166,9 @@ def fake_call(**kwargs): assert captured["kwargs"]["input"] == [{"text": "hello"}, {"text": "world"}] assert captured["kwargs"]["api_key"] == "sk-test" assert captured["kwargs"]["dimension"] == 1024 - assert captured["base_http_api_url"] == ("https://dashscope.aliyuncs.com/api/v1") + assert captured["kwargs"]["base_address"] == ( + "https://dashscope.aliyuncs.com/api/v1" + ) @pytest.mark.asyncio @@ -169,14 +183,68 @@ def fake_call(**kwargs): output={"embeddings": [{"embedding": [0.1, 0.2], "index": 0}]} ) - import astrbot.core.provider.sources.dashscope_embedding_source as mod - - monkeypatch.setattr(mod.MultiModalEmbedding, "call", fake_call) + _patch_sdk(monkeypatch, multimodal=fake_call) result = await provider.get_embeddings(["hi"]) assert result == [[0.1, 0.2]] +# --------------------------------------------------------------------------- +# get_embeddings — edge cases +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_empty_input_returns_early(monkeypatch): + """An empty input list must not invoke the SDK at all.""" + provider = _make_provider() + + _patch_sdk( + monkeypatch, + text=lambda **kw: pytest.fail("should not call SDK for empty input"), + multimodal=lambda **kw: pytest.fail("should not call SDK for empty input"), + ) + + result = await provider.get_embeddings([]) + assert result == [] + + +@pytest.mark.asyncio +async def test_zero_embedding_dimension_omitted(monkeypatch): + """A zero dimension is invalid and must be omitted from the SDK call.""" + provider = _make_provider({"embedding_dimensions": 0}) + captured: dict = {} + + def fake_call(**kwargs): + captured["kwargs"] = kwargs + return _FakeResponse( + output={"embeddings": [{"embedding": [0.1], "text_index": 0}]} + ) + + _patch_sdk(monkeypatch, text=fake_call) + + await provider.get_embeddings(["hi"]) + assert "dimension" not in captured["kwargs"] + + +@pytest.mark.asyncio +async def test_non_int_embedding_dimension_omitted(monkeypatch): + """A non-integer dimension is invalid and must be omitted from the SDK call.""" + provider = _make_provider({"embedding_dimensions": "abc"}) + captured: dict = {} + + def fake_call(**kwargs): + captured["kwargs"] = kwargs + return _FakeResponse( + output={"embeddings": [{"embedding": [0.1], "text_index": 0}]} + ) + + _patch_sdk(monkeypatch, text=fake_call) + + await provider.get_embeddings(["hi"]) + assert "dimension" not in captured["kwargs"] + + # --------------------------------------------------------------------------- # get_embedding (single text convenience wrapper) # --------------------------------------------------------------------------- @@ -211,9 +279,7 @@ def fake_call(**kwargs): request_id="req-123", ) - import astrbot.core.provider.sources.dashscope_embedding_source as mod - - monkeypatch.setattr(mod.TextEmbedding, "call", fake_call) + _patch_sdk(monkeypatch, text=fake_call) with pytest.raises( Exception, @@ -231,9 +297,7 @@ async def test_multimodal_error_url_uses_multimodal_path(monkeypatch): def fake_call(**kwargs): return _FakeResponse(status_code=404, code="Unkonwn", message="") - import astrbot.core.provider.sources.dashscope_embedding_source as mod - - monkeypatch.setattr(mod.MultiModalEmbedding, "call", fake_call) + _patch_sdk(monkeypatch, multimodal=fake_call) with pytest.raises( Exception, @@ -245,10 +309,7 @@ def fake_call(**kwargs): @pytest.mark.asyncio async def test_no_embeddings_raises(monkeypatch): provider = _make_provider() - monkeypatch.setattr( - "astrbot.core.provider.sources.dashscope_embedding_source.TextEmbedding.call", - lambda **kw: _FakeResponse(output={}), - ) + _patch_sdk(monkeypatch, text=lambda **kw: _FakeResponse(output={})) with pytest.raises(Exception, match="No embeddings"): await provider.get_embeddings(["hi"])