feat(models): 增强模型配置能力
This commit is contained in:
parent
2b8cc37592
commit
6438f8b7c2
@ -26,7 +26,7 @@
|
||||
- [ ] add model retry times to agent context config
|
||||
- [ ] 添加用户级别的 Skills 的安装
|
||||
- [ ] 子智能体的优化,参考 PR 的方案。
|
||||
- [ ] 附件上传能够支持转换为 PDF
|
||||
- [ ] 附件上传能够支持转换为 PDF,待办:查看 OCR 模型的状态,样式优化,保存的文件名不对
|
||||
- [ ] 参考 PR,实现内置 Dashscope 的 Embedding 和 rerank 的方法
|
||||
- [ ] 优化知识库的 API 接口设计,使用 /{db_id}/xxx 的形式,整合 mindmap / eval 接口
|
||||
- [x] allow multi-hop qa generate
|
||||
|
||||
@ -41,8 +41,24 @@ BUILTIN_PROVIDERS: list[dict[str, Any]] = [
|
||||
"provider_id": "alibaba",
|
||||
"display_name": "DashScope",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"embedding_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings",
|
||||
"rerank_base_url": "https://dashscope.aliyuncs.com/compatible-api/v1/reranks",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"capabilities": ["chat", "embedding", "rerank"],
|
||||
"models_endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
|
||||
"enabled_models": [
|
||||
{
|
||||
"id": "text-embedding-v4",
|
||||
"type": "embedding",
|
||||
"display_name": "text-embedding-v4",
|
||||
"dimension": 1024,
|
||||
},
|
||||
{
|
||||
"id": "qwen3-rerank",
|
||||
"type": "rerank",
|
||||
"display_name": "qwen3-rerank",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"provider_id": "alibaba-coding-plan-cn",
|
||||
|
||||
@ -91,7 +91,12 @@ class BaseEmbeddingModel(ABC):
|
||||
|
||||
async def test_connection(self) -> tuple[bool, str]:
|
||||
try:
|
||||
await self.aencode(["Hello world"])
|
||||
embeddings = await self.aencode(["Hello world"])
|
||||
if self.dimension not in (None, ""):
|
||||
actual_dimension = len(embeddings[0]) if embeddings else 0
|
||||
expected_dimension = int(self.dimension)
|
||||
if actual_dimension != expected_dimension:
|
||||
return False, f"Embedding 维度不一致:配置 {expected_dimension},实际 {actual_dimension}"
|
||||
return True, "连接正常"
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
|
||||
@ -105,6 +105,19 @@ class BaseReranker(ABC):
|
||||
return asyncio.run(self.acompute_score(sentence_pairs, batch_size, max_length, normalize))
|
||||
raise RuntimeError("compute_score cannot be used while an event loop is running. Use acompute_score instead.")
|
||||
|
||||
async def test_connection(self) -> tuple[bool, str]:
|
||||
try:
|
||||
scores = await self._batch_rerank("test query", ["test document"], max_length=128)
|
||||
if scores:
|
||||
return True, "连接正常"
|
||||
return False, "响应无效"
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"Rerank connection test failed: {error_msg}")
|
||||
return False, error_msg
|
||||
finally:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
|
||||
@ -161,7 +161,7 @@ class ModelCache:
|
||||
|
||||
for model in provider.enabled_models or []:
|
||||
model_type = model.get("type", "chat")
|
||||
base_url = self._get_base_url_for_type(provider, model_type)
|
||||
base_url = model.get("base_url_override") or self._get_base_url_for_type(provider, model_type)
|
||||
|
||||
info = ModelInfo(
|
||||
provider_id=provider.provider_id,
|
||||
|
||||
@ -387,10 +387,14 @@ async def test_model_status_by_spec(spec: str) -> dict:
|
||||
"model_type": "embedding",
|
||||
}
|
||||
if info.model_type == "rerank":
|
||||
from yuxi.models.rerank import get_reranker
|
||||
|
||||
model = get_reranker(spec)
|
||||
success, message = await model.test_connection()
|
||||
return {
|
||||
"spec": spec,
|
||||
"status": "unsupported",
|
||||
"message": "暂不支持 rerank 模型在线连接测试",
|
||||
"status": "available" if success else "unavailable",
|
||||
"message": "连接正常" if success else message,
|
||||
"model_type": "rerank",
|
||||
}
|
||||
|
||||
|
||||
@ -87,6 +87,7 @@ async def create_provider(
|
||||
payload.model_dump(exclude_none=True),
|
||||
current_user.username,
|
||||
)
|
||||
await db.commit()
|
||||
await _refresh_model_cache()
|
||||
return {"success": True, "data": provider.to_dict()}
|
||||
except ValueError as e:
|
||||
@ -138,6 +139,7 @@ async def update_provider(
|
||||
provider = await update_provider_config(db, provider_id, data, current_user.username)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"供应商 {provider_id} 不存在")
|
||||
await db.commit()
|
||||
await _refresh_model_cache()
|
||||
return {"success": True, "data": provider.to_dict()}
|
||||
except HTTPException:
|
||||
@ -159,6 +161,7 @@ async def delete_provider(
|
||||
deleted = await delete_provider_config(db, provider_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"供应商 {provider_id} 不存在")
|
||||
await db.commit()
|
||||
await _refresh_model_cache()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from server.routers import model_provider_router
|
||||
from server.routers.model_provider_router import ModelProviderPayload
|
||||
|
||||
|
||||
@ -15,3 +18,39 @@ def test_model_provider_payload_accepts_embedding_and_rerank_urls():
|
||||
|
||||
assert data["embedding_base_url"] == "https://api.example.com/v1/embeddings"
|
||||
assert data["rerank_base_url"] == "https://api.example.com/v1/rerank"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_provider_commits_before_refreshing_cache(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Db:
|
||||
async def commit(self):
|
||||
calls.append("commit")
|
||||
|
||||
class User:
|
||||
username = "admin"
|
||||
|
||||
class Provider:
|
||||
def to_dict(self):
|
||||
return {"provider_id": "alibaba"}
|
||||
|
||||
async def fake_update_provider_config(db, provider_id, data, username):
|
||||
calls.append("update")
|
||||
return Provider()
|
||||
|
||||
async def fake_refresh_model_cache():
|
||||
calls.append("refresh")
|
||||
|
||||
monkeypatch.setattr(model_provider_router, "update_provider_config", fake_update_provider_config)
|
||||
monkeypatch.setattr(model_provider_router, "_refresh_model_cache", fake_refresh_model_cache)
|
||||
|
||||
result = await model_provider_router.update_provider(
|
||||
"alibaba",
|
||||
ModelProviderPayload(enabled_models=[]),
|
||||
current_user=User(),
|
||||
db=Db(),
|
||||
)
|
||||
|
||||
assert result == {"success": True, "data": {"provider_id": "alibaba"}}
|
||||
assert calls == ["update", "commit", "refresh"]
|
||||
|
||||
32
backend/test/unit/services/test_model_cache.py
Normal file
32
backend/test/unit/services/test_model_cache.py
Normal file
@ -0,0 +1,32 @@
|
||||
from yuxi.services.model_cache import ModelCache
|
||||
|
||||
|
||||
def test_model_cache_prefers_model_base_url_override(monkeypatch):
|
||||
saved_cache = {}
|
||||
|
||||
class Provider:
|
||||
is_enabled = True
|
||||
provider_id = "alibaba"
|
||||
api_key = "sk-test"
|
||||
api_key_env = None
|
||||
provider_type = "openai"
|
||||
base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
embedding_base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
|
||||
rerank_base_url = "https://dashscope.aliyuncs.com/compatible-api/v1/reranks"
|
||||
headers_json = {}
|
||||
extra_json = {}
|
||||
enabled_models = [
|
||||
{
|
||||
"id": "qwen3-rerank",
|
||||
"type": "rerank",
|
||||
"display_name": "Qwen3 Rerank",
|
||||
"base_url_override": "https://invalid.example/rerank",
|
||||
}
|
||||
]
|
||||
|
||||
cache = ModelCache()
|
||||
monkeypatch.setattr(cache, "_save_cache", lambda data: saved_cache.update(data))
|
||||
|
||||
cache.rebuild([Provider()])
|
||||
|
||||
assert saved_cache["alibaba:qwen3-rerank"].base_url == "https://invalid.example/rerank"
|
||||
@ -157,6 +157,20 @@ def test_builtin_siliconflow_provider_includes_default_runnable_models():
|
||||
assert "base_url_override" not in models["Pro/BAAI/bge-reranker-v2-m3"]
|
||||
|
||||
|
||||
def test_builtin_dashscope_provider_includes_default_embedding_and_rerank_models():
|
||||
provider = next(item for item in BUILTIN_PROVIDERS if item["provider_id"] == "alibaba")
|
||||
models = {model["id"]: model for model in provider["enabled_models"]}
|
||||
|
||||
assert provider["capabilities"] == ["chat", "embedding", "rerank"]
|
||||
assert provider["embedding_base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
|
||||
assert provider["rerank_base_url"] == "https://dashscope.aliyuncs.com/compatible-api/v1/reranks"
|
||||
assert "embedding_models_endpoint" not in provider
|
||||
assert "rerank_models_endpoint" not in provider
|
||||
assert models["text-embedding-v4"]["type"] == "embedding"
|
||||
assert models["text-embedding-v4"]["dimension"] == 2048
|
||||
assert models["qwen3-rerank"]["type"] == "rerank"
|
||||
|
||||
|
||||
def testcheck_credential_status_disabled_provider_always_ok():
|
||||
"""未启用的 provider 无论凭证如何配置,状态始终为 ok。"""
|
||||
class Provider:
|
||||
|
||||
@ -47,6 +47,40 @@ def test_select_embedding_model_loads_model_from_cache(monkeypatch):
|
||||
assert model.dimension == 1024
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_connection_checks_configured_dimension(monkeypatch):
|
||||
model = OtherEmbedding(
|
||||
model="namespace/embedding-model",
|
||||
base_url="https://example.com/v1/embeddings",
|
||||
api_key="test-key",
|
||||
dimension=3,
|
||||
)
|
||||
|
||||
async def fake_aencode(_messages):
|
||||
return [[0.1, 0.2, 0.3]]
|
||||
|
||||
monkeypatch.setattr(model, "aencode", fake_aencode)
|
||||
|
||||
assert await model.test_connection() == (True, "连接正常")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_connection_reports_dimension_mismatch(monkeypatch):
|
||||
model = OtherEmbedding(
|
||||
model="namespace/embedding-model",
|
||||
base_url="https://example.com/v1/embeddings",
|
||||
api_key="test-key",
|
||||
dimension=4,
|
||||
)
|
||||
|
||||
async def fake_aencode(_messages):
|
||||
return [[0.1, 0.2, 0.3]]
|
||||
|
||||
monkeypatch.setattr(model, "aencode", fake_aencode)
|
||||
|
||||
assert await model.test_connection() == (False, "Embedding 维度不一致:配置 4,实际 3")
|
||||
|
||||
|
||||
def test_get_reranker_loads_model_from_cache(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"yuxi.models.rerank.model_cache.get_model_info",
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { Globe, Plus, RefreshCw, Search, Settings2, Trash2, CheckCircle2, Edit3 } from 'lucide-vue-next'
|
||||
import {
|
||||
Globe,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
Edit3,
|
||||
LayersPlus
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { modelProviderApi } from '@/apis/system_api'
|
||||
import { modelIcons } from '@/utils/modelIcon'
|
||||
@ -14,6 +24,8 @@ const remoteLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const providers = ref([])
|
||||
const searchQuery = ref('')
|
||||
const modelTestLoadingBySpec = ref({})
|
||||
const modelTestResultBySpec = ref({})
|
||||
|
||||
// Provider form state
|
||||
const showProviderModal = ref(false)
|
||||
@ -146,6 +158,23 @@ const getModelId = (model) => {
|
||||
return model.id
|
||||
}
|
||||
|
||||
const buildModelSpec = (providerId, modelId) => `${providerId}:${modelId}`
|
||||
|
||||
const getModelTestTitle = (providerId, model) => {
|
||||
const spec = buildModelSpec(providerId, model.id)
|
||||
const result = modelTestResultBySpec.value[spec]
|
||||
if (!result) return '测试连接'
|
||||
|
||||
const statusText =
|
||||
{
|
||||
available: '可用',
|
||||
unavailable: '不可用',
|
||||
unsupported: '暂不支持',
|
||||
error: '错误'
|
||||
}[result.status] || '未知'
|
||||
return `${statusText}: ${result.message || '无详细信息'}`
|
||||
}
|
||||
|
||||
const getInputModalities = (model) => {
|
||||
if (!model) return []
|
||||
if (model.input_modalities) return model.input_modalities
|
||||
@ -281,9 +310,9 @@ const openEditProviderModal = (provider) => {
|
||||
base_url: provider.base_url || '',
|
||||
embedding_base_url: provider.embedding_base_url || '',
|
||||
rerank_base_url: provider.rerank_base_url || '',
|
||||
models_endpoint: provider.models_endpoint || '/models',
|
||||
embedding_models_endpoint: provider.embedding_models_endpoint || '/embeddings/models',
|
||||
rerank_models_endpoint: provider.rerank_models_endpoint || '',
|
||||
models_endpoint: provider.models_endpoint ?? '',
|
||||
embedding_models_endpoint: provider.embedding_models_endpoint ?? '',
|
||||
rerank_models_endpoint: provider.rerank_models_endpoint ?? '',
|
||||
api_key_env: provider.api_key_env || '',
|
||||
api_key: provider.api_key || '',
|
||||
capabilities: provider.capabilities?.length ? provider.capabilities : ['chat'],
|
||||
@ -420,6 +449,36 @@ const normalizeModel = (model = {}) => ({
|
||||
extra: model.extra || {}
|
||||
})
|
||||
|
||||
const testModelConnection = async (providerId, model) => {
|
||||
const spec = buildModelSpec(providerId, model.id)
|
||||
if (modelTestLoadingBySpec.value[spec]) return
|
||||
|
||||
modelTestLoadingBySpec.value = { ...modelTestLoadingBySpec.value, [spec]: true }
|
||||
try {
|
||||
const result = await modelProviderApi.getModelStatusBySpec(spec)
|
||||
const status = result.data || { spec, status: 'error', message: '检查失败' }
|
||||
modelTestResultBySpec.value = { ...modelTestResultBySpec.value, [spec]: status }
|
||||
|
||||
if (status.status === 'available') {
|
||||
message.success(`${getModelDisplayName(model)} 连接正常`)
|
||||
} else if (status.status === 'unsupported') {
|
||||
message.warning(status.message || '暂不支持测试该类型模型')
|
||||
} else if (status.status === 'unavailable') {
|
||||
message.warning(status.message || '模型连接不可用')
|
||||
} else {
|
||||
message.error(status.message || '模型连接测试失败')
|
||||
}
|
||||
} catch (error) {
|
||||
modelTestResultBySpec.value = {
|
||||
...modelTestResultBySpec.value,
|
||||
[spec]: { spec, status: 'error', message: error.message || '检查失败' }
|
||||
}
|
||||
message.error(error.message || '模型连接测试失败')
|
||||
} finally {
|
||||
modelTestLoadingBySpec.value = { ...modelTestLoadingBySpec.value, [spec]: false }
|
||||
}
|
||||
}
|
||||
|
||||
const addModelFromRemote = async (providerId, remoteModel) => {
|
||||
const provider = providers.value.find((p) => p.provider_id === providerId)
|
||||
if (!provider) return
|
||||
@ -827,8 +886,10 @@ defineExpose({
|
||||
v-if="model.source === 'manual'"
|
||||
class="type-tag manual"
|
||||
title="管理员手动添加"
|
||||
>手动</span
|
||||
aria-label="管理员手动添加"
|
||||
>
|
||||
<LayersPlus :size="12" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="col-context">{{ formatContextLength(model.context_length) }}</span>
|
||||
<span class="col-dim">
|
||||
@ -841,6 +902,16 @@ defineExpose({
|
||||
<span v-else>{{ model.dimension || '-' }}</span>
|
||||
</span>
|
||||
<span class="col-ops">
|
||||
<a-button
|
||||
size="small"
|
||||
class="model-test-button"
|
||||
:title="getModelTestTitle(currentProviderForModels.provider_id, model)"
|
||||
:loading="modelTestLoadingBySpec[buildModelSpec(currentProviderForModels.provider_id, model.id)]"
|
||||
:disabled="modelTestLoadingBySpec[buildModelSpec(currentProviderForModels.provider_id, model.id)]"
|
||||
@click="testModelConnection(currentProviderForModels.provider_id, model)"
|
||||
>
|
||||
测试
|
||||
</a-button>
|
||||
<a-button size="small" class="lucide-icon-btn" @click="openModelConfigModal(model)">
|
||||
<Settings2 :size="13" />
|
||||
</a-button>
|
||||
@ -1063,7 +1134,7 @@ defineExpose({
|
||||
.table-head,
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 70px 60px 110px;
|
||||
grid-template-columns: 1fr 80px 70px 60px 150px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
@ -1134,6 +1205,10 @@ defineExpose({
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.model-test-button {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user