diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 642a7bed50..598b863ea4 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1818,6 +1818,7 @@ "embedding_api_base": "", "embedding_model": "", "embedding_dimensions": 1024, + "embedding_dimensions_as_request_param": False, "timeout": 20, "proxy": "", }, @@ -2190,6 +2191,12 @@ "hint": "嵌入向量的维度。根据模型不同,可能需要调整,请参考具体模型的文档。此配置项请务必填写正确,否则将导致向量数据库无法正常工作。", "_special": "get_embedding_dim", }, + "embedding_dimensions_as_request_param": { + "description": "将嵌入维度作为请求参数发送", + "type": "bool", + "hint": "开启后会把嵌入维度作为 dimensions 参数发送给 OpenAI-compatible Embedding API。仅在模型明确支持该参数时开启。", + "invisible": True, + }, "embedding_model": { "description": "嵌入模型", "type": "string", diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index ae531996ae..05b7fe504e 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -64,15 +64,23 @@ async def get_embeddings(self, text: list[str]) -> list[list[float]]: def _embedding_kwargs(self) -> dict: """构建嵌入请求的可选参数""" - kwargs = {} - if "embedding_dimensions" in self.provider_config: - try: - kwargs["dimensions"] = int(self.provider_config["embedding_dimensions"]) - except (ValueError, TypeError): - logger.warning( - f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored." - ) - return kwargs + if not self.provider_config.get("embedding_dimensions_as_request_param", False): + return {} + + if "embedding_dimensions" not in self.provider_config: + return {} + + try: + dimensions = int(self.provider_config["embedding_dimensions"]) + except (ValueError, TypeError): + logger.warning( + f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored." + ) + return {} + + if dimensions <= 0: + return {} + return {"dimensions": dimensions} def get_dim(self) -> int: """获取向量的维度""" diff --git a/dashboard/src/components/shared/AstrBotConfig.vue b/dashboard/src/components/shared/AstrBotConfig.vue index 87bed80e42..f3d52e05c7 100644 --- a/dashboard/src/components/shared/AstrBotConfig.vue +++ b/dashboard/src/components/shared/AstrBotConfig.vue @@ -272,6 +272,7 @@ function hasVisibleItemsAfter(items, currentIndex) { :plugin-name="pluginName" :plugin-i18n="pluginI18n" :config-key="getItemPath(key)" + :config-root="iterable" :loading="loadingEmbeddingDim" :show-fullscreen-btn="!!metadata[metadataKey].items[key]?.editor_mode" @get-embedding-dim="getEmbeddingDimensions(iterable)" @@ -322,6 +323,7 @@ function hasVisibleItemsAfter(items, currentIndex) { :plugin-name="pluginName" :plugin-i18n="pluginI18n" :config-key="getItemPath(metadataKey)" + :config-root="iterable" /> diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index f791dd540c..303f504a83 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -73,6 +73,25 @@ > {{ t('core.common.autoDetect') }} + + + @@ -307,11 +326,24 @@ const listSelectItems = computed(() => : [] ) +const hasEmbeddingDimensionsRequestParam = computed(() => + props.configRoot + && Object.prototype.hasOwnProperty.call( + props.configRoot, + 'embedding_dimensions_as_request_param' + ) +) + function toNumber(val) { const n = parseFloat(val) return isNaN(n) ? 0 : n } +function setEmbeddingDimensionsRequestParam(val) { + if (!props.configRoot) return + props.configRoot.embedding_dimensions_as_request_param = Boolean(val) +} + function getLabel(itemMeta, index, option) { const labels = getTranslatedLabels(itemMeta) return labels ? labels[index] : option diff --git a/dashboard/src/i18n/locales/en-US/core/common.json b/dashboard/src/i18n/locales/en-US/core/common.json index 973feea542..19c5dd6e6c 100644 --- a/dashboard/src/i18n/locales/en-US/core/common.json +++ b/dashboard/src/i18n/locales/en-US/core/common.json @@ -38,6 +38,8 @@ "no": "No", "imagePreview": "Image Preview", "autoDetect": "Auto Detect", + "embeddingDimensionsRequestParam": "Request Param", + "embeddingDimensionsRequestParamHint": "When enabled, the embedding dimension is sent as the API dimensions parameter. Disabled by default; enable only for models that explicitly support it.", "dialog": { "confirmTitle": "Confirm Action", "confirmMessage": "Are you sure you want to perform this action?", diff --git a/dashboard/src/i18n/locales/ru-RU/core/common.json b/dashboard/src/i18n/locales/ru-RU/core/common.json index e9f0d4a112..e406f0f7c5 100644 --- a/dashboard/src/i18n/locales/ru-RU/core/common.json +++ b/dashboard/src/i18n/locales/ru-RU/core/common.json @@ -38,6 +38,8 @@ "no": "Нет", "imagePreview": "Предпросмотр изображения", "autoDetect": "Автоопределение", + "embeddingDimensionsRequestParam": "Параметр API", + "embeddingDimensionsRequestParamHint": "Если включено, размерность эмбеддинга отправляется как параметр dimensions. По умолчанию выключено; включайте только для моделей, которые явно поддерживают этот параметр.", "dialog": { "confirmTitle": "Подтверждение", "confirmMessage": "Вы уверены, что хотите выполнить это действие?", @@ -130,4 +132,4 @@ "subtitle": "Файл FIRST_NOTICE.md не найден или пуст." } } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/core/common.json b/dashboard/src/i18n/locales/zh-CN/core/common.json index fa1a8bf8b6..6bb868e976 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/common.json +++ b/dashboard/src/i18n/locales/zh-CN/core/common.json @@ -38,6 +38,8 @@ "no": "否", "imagePreview": "图片预览", "autoDetect": "自动检测", + "embeddingDimensionsRequestParam": "请求参数", + "embeddingDimensionsRequestParamHint": "开启后会把嵌入维度作为 dimensions 参数发送给 API。默认关闭;仅在模型明确支持时开启。", "dialog": { "confirmTitle": "确认操作", "confirmMessage": "你确定要执行此操作吗?", diff --git a/tests/test_openai_embedding_source.py b/tests/test_openai_embedding_source.py new file mode 100644 index 0000000000..354fa46db4 --- /dev/null +++ b/tests/test_openai_embedding_source.py @@ -0,0 +1,104 @@ +from types import SimpleNamespace + +import pytest + +from astrbot.core.provider.sources.openai_embedding_source import ( + OpenAIEmbeddingProvider, +) + + +class FakeEmbeddingsClient: + def __init__(self): + self.calls = [] + + async def create(self, **kwargs): + self.calls.append(kwargs) + input_value = kwargs["input"] + if isinstance(input_value, list): + data = [ + SimpleNamespace(embedding=[float(index), 0.0, 1.0]) + for index, _ in enumerate(input_value) + ] + else: + data = [SimpleNamespace(embedding=[1.0, 2.0, 3.0])] + return SimpleNamespace(data=data) + + +@pytest.mark.asyncio +async def test_openai_embedding_does_not_send_dimensions_by_default(): + provider = OpenAIEmbeddingProvider( + { + "id": "openai-compatible-embedding", + "embedding_api_key": "test-key", + "embedding_api_base": "https://example.com/v1", + "embedding_model": "BAAI/bge-m3", + "embedding_dimensions": 1024, + }, + {}, + ) + fake_embeddings = FakeEmbeddingsClient() + provider.client = SimpleNamespace(embeddings=fake_embeddings) + + embedding = await provider.get_embedding("hello") + embeddings = await provider.get_embeddings(["hello", "world"]) + + assert embedding == [1.0, 2.0, 3.0] + assert embeddings == [[0.0, 0.0, 1.0], [1.0, 0.0, 1.0]] + assert provider.get_dim() == 1024 + assert fake_embeddings.calls == [ + {"input": "hello", "model": "BAAI/bge-m3"}, + {"input": ["hello", "world"], "model": "BAAI/bge-m3"}, + ] + + +@pytest.mark.asyncio +async def test_openai_embedding_sends_dimensions_when_explicitly_enabled(): + provider = OpenAIEmbeddingProvider( + { + "id": "openai-compatible-embedding", + "embedding_api_key": "test-key", + "embedding_api_base": "https://api.openai.com/v1", + "embedding_model": "text-embedding-3-small", + "embedding_dimensions": 512, + "embedding_dimensions_as_request_param": True, + }, + {}, + ) + fake_embeddings = FakeEmbeddingsClient() + provider.client = SimpleNamespace(embeddings=fake_embeddings) + + embedding = await provider.get_embedding("hello") + + assert embedding == [1.0, 2.0, 3.0] + assert provider.get_dim() == 512 + assert fake_embeddings.calls == [ + { + "input": "hello", + "model": "text-embedding-3-small", + "dimensions": 512, + }, + ] + + +@pytest.mark.asyncio +async def test_openai_embedding_omits_dimensions_when_dimension_not_configured(): + provider = OpenAIEmbeddingProvider( + { + "id": "openai-compatible-embedding", + "embedding_api_key": "test-key", + "embedding_api_base": "https://example.com/v1", + "embedding_model": "BAAI/bge-m3", + "embedding_dimensions_as_request_param": True, + }, + {}, + ) + fake_embeddings = FakeEmbeddingsClient() + provider.client = SimpleNamespace(embeddings=fake_embeddings) + + embeddings = await provider.get_embeddings(["hello", "world"]) + + assert embeddings == [[0.0, 0.0, 1.0], [1.0, 0.0, 1.0]] + assert provider.get_dim() == 0 + assert fake_embeddings.calls == [ + {"input": ["hello", "world"], "model": "BAAI/bge-m3"}, + ]