feat(models): 增强模型配置能力

This commit is contained in:
Wenjie Zhang 2026-05-27 19:56:26 +08:00
parent 2b8cc37592
commit 6438f8b7c2
12 changed files with 246 additions and 11 deletions

View File

@ -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

View File

@ -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",

View File

@ -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)

View File

@ -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()

View File

@ -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,

View File

@ -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",
}

View File

@ -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}

View File

@ -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"]

View 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"

View File

@ -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:

View File

@ -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",

View File

@ -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;