[feat] 添加模型创建和更新时对供应商类别支持的验证,增强前端供应商选择功能

This commit is contained in:
extreme committed 2026-04-14 11:05:21 +08:00
1 parent 5e7d40da9a
commit 0b710a35dc
3 files changed
+202 -7

No files matched your search

+39 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,7 +19,11 @@ from app.schemas.llm import (
ProviderSupportedRead,
ProviderUpdate,
)
from app.services.llm.provider_registry import list_registered_providers
from app.services.llm.provider_registry import (
is_provider_category_supported,
list_registered_providers,
resolve_provider_key_from_name,
)
from app.bootstrap import bootstrap_all_registries
from app.services.common import (
create_and_refresh,
@@ -172,7 +177,14 @@ async def create_model(
detail=entity_already_exists("Model"),
status_code=400,
)
await require_entity(db, Provider, body.provider_id, detail=entity_not_found("Provider"), status_code=400)
provider = await require_entity(
db,
Provider,
body.provider_id,
detail=entity_not_found("Provider"),
status_code=400,
)
_ensure_provider_supports_category(provider=provider, category=body.category)
if body.is_default:
await db.execute(update(Model).where(Model.category == body.category).values(is_default=False))
await db.flush()
@@ -217,8 +229,17 @@ async def update_model(
detail=entity_not_found("Provider"),
status_code=400,
)
target_category = update_data.get("category", model.category)
target_provider_id = update_data.get("provider_id", model.provider_id)
target_provider = await require_entity(
db,
Provider,
target_provider_id,
detail=entity_not_found("Provider"),
status_code=400,
)
_ensure_provider_supports_category(provider=target_provider, category=target_category)
if update_data.get("is_default") is True:
target_category = update_data.get("category", model.category)
await db.execute(update(Model).where(Model.category == target_category).values(is_default=False))
await db.flush()
patch_model(model, update_data)
@@ -279,3 +300,18 @@ def list_supported_providers(*, category: ModelCategoryKey | None) -> list[Provi
)
for spec in specs
]
def _ensure_provider_supports_category(*, provider: Provider, category: ModelCategoryKey | str) -> None:
bootstrap_all_registries()
normalized_category = (
category
if isinstance(category, ModelCategoryKey)
else ModelCategoryKey((str(category or "")).strip().lower())
)
provider_key = resolve_provider_key_from_name(provider.name)
if not is_provider_category_supported(provider_key, normalized_category):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Provider {provider.name!r} does not support category={normalized_category.value}",
)
+73
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from fastapi import HTTPException
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
@@ -178,3 +179,75 @@ async def test_list_models_paginated_returns_filtered_items() -> None:
assert resp.data.pagination.total == 1
assert [item.id for item in resp.data.items] == ["m2"]
await engine.dispose()
@pytest.mark.asyncio
async def test_create_model_rejects_unsupported_category_for_provider() -> None:
db, engine = await _build_session()
async with db:
await create_provider(
db,
body=ProviderCreate(
id="p-bailian",
name="阿里百炼",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key="k",
),
)
with pytest.raises(HTTPException) as exc_info:
await create_model(
db,
body=ModelCreate(
id="m-video-invalid",
name="qwen-vl-video",
category=ModelCategoryKey.video,
provider_id="p-bailian",
),
)
assert exc_info.value.status_code == 400
assert "does not support category=video" in str(exc_info.value.detail)
await engine.dispose()
@pytest.mark.asyncio
async def test_update_model_rejects_switch_to_unsupported_provider_category_combo() -> None:
db, engine = await _build_session()
async with db:
await create_provider(
db,
body=ProviderCreate(
id="p-openai",
name="OpenAI",
base_url="https://api.openai.com/v1",
api_key="k",
),
)
await create_provider(
db,
body=ProviderCreate(
id="p-bailian",
name="阿里百炼",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key="k",
),
)
await create_model(
db,
body=ModelCreate(
id="m-video-ok",
name="sora",
category=ModelCategoryKey.video,
provider_id="p-openai",
),
)
with pytest.raises(HTTPException) as exc_info:
await update_model(
db,
model_id="m-video-ok",
body=ModelUpdate(provider_id="p-bailian"),
)
assert exc_info.value.status_code == 400
assert "does not support category=video" in str(exc_info.value.detail)
await engine.dispose()
+90 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, useMemo } from 'react'
import {
Alert,
Layout,
Input,
Button,
@@ -35,7 +36,12 @@ import {
ThunderboltOutlined,
} from '@ant-design/icons'
import { LlmService } from '../../../services/generated/services/LlmService'
import type { ModelRead, ModelCategoryKey, ProviderRead } from '../../../services/generated'
import type {
ModelRead,
ModelCategoryKey,
ProviderRead,
ProviderSupportedRead,
} from '../../../services/generated'
import {
MODEL_CATEGORIES,
categoryLabelMap,
@@ -45,6 +51,7 @@ import {
export default function ModelsTab() {
const [providers, setProviders] = useState<ProviderRead[]>([])
const [supportedProviders, setSupportedProviders] = useState<ProviderSupportedRead[]>([])
const [models, setModels] = useState<ModelRead[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
@@ -56,14 +63,16 @@ export default function ModelsTab() {
const [categoryFilter, setCategoryFilter] = useState<ModelCategoryKey | null>(null)
const [modelModalOpen, setModelModalOpen] = useState(false)
const [modelEditing, setModelEditing] = useState<ModelRead | null>(null)
const [providerOptionsLoading, setProviderOptionsLoading] = useState(true)
const [form] = Form.useForm()
const selectedFormCategory = Form.useWatch<ModelCategoryKey | undefined>('category', form)
const { lg } = Grid.useBreakpoint()
const isLargeScreen = lg ?? false
const load = async () => {
setLoading(true)
try {
const [provRes, modelsRes] = await Promise.all([
const [provRes, modelsRes, supportedRes] = await Promise.all([
LlmService.listProvidersApiV1LlmProvidersGet({ page: 1, pageSize: 100 }),
LlmService.listModelsApiV1LlmModelsGet({
q: search.trim() || undefined,
@@ -72,13 +81,16 @@ export default function ModelsTab() {
page: 1,
pageSize: 100,
}),
LlmService.listSupportedProvidersApiV1LlmProvidersSupportedGet({}),
])
setProviders(provRes.data?.items ?? [])
setModels(modelsRes.data?.items ?? [])
setSupportedProviders(supportedRes.data ?? [])
} catch {
message.error('加载失败')
} finally {
setLoading(false)
setProviderOptionsLoading(false)
}
}
@@ -112,6 +124,60 @@ export default function ModelsTab() {
const getProviderName = (id: string) => providers.find((p) => p.id === id)?.name ?? id
const resolveProviderSpec = (providerName: string) =>
supportedProviders.find(
(spec) => spec.display_name === providerName || (spec.aliases?.length && spec.aliases.includes(providerName)),
)
const filteredProviderOptions = useMemo(() => {
if (!selectedFormCategory) return providers.map((p) => ({ label: p.name, value: p.id }))
return providers
.filter((provider) => {
const spec = resolveProviderSpec(provider.name)
return !!spec?.supported_categories?.includes(selectedFormCategory)
})
.map((p) => ({ label: p.name, value: p.id }))
}, [providers, selectedFormCategory, supportedProviders])
const editingUnsupportedProviderOption = useMemo(() => {
if (!modelEditing) return null
const provider = providers.find((p) => p.id === modelEditing.provider_id)
if (!provider) return null
const alreadyInOptions = filteredProviderOptions.some((opt) => opt.value === provider.id)
if (alreadyInOptions) return null
return {
label: `${provider.name}(历史/当前不支持所选类别)`,
value: provider.id,
}
}, [filteredProviderOptions, modelEditing, providers])
const providerSelectOptions = useMemo(
() =>
editingUnsupportedProviderOption
? [editingUnsupportedProviderOption, ...filteredProviderOptions]
: filteredProviderOptions,
[editingUnsupportedProviderOption, filteredProviderOptions],
)
const unsupportedProviderWarning = useMemo(() => {
if (!editingUnsupportedProviderOption || !selectedFormCategory) return null
const provider = providers.find((p) => p.id === editingUnsupportedProviderOption.value)
if (!provider) return null
const categoryLabel = categoryLabelMap[selectedFormCategory]
return `当前模型绑定供应商「${provider.name}」不支持「${categoryLabel}」类别,保存前建议切换到支持该类别的供应商。`
}, [editingUnsupportedProviderOption, providers, selectedFormCategory])
useEffect(() => {
if (!selectedFormCategory) return
const providerId = form.getFieldValue('provider_id') as string | undefined
if (!providerId) return
const stillSupported = filteredProviderOptions.some((opt) => opt.value === providerId)
const keepHistoricalEditingProvider = modelEditing?.provider_id === providerId
if (!stillSupported && !keepHistoricalEditingProvider) {
form.setFieldsValue({ provider_id: undefined })
message.warning('当前供应商不支持所选模型类别,请重新选择')
}
}, [filteredProviderOptions, form, modelEditing, selectedFormCategory])
const handleSaveModel = async () => {
try {
const values = await form.validateFields()
@@ -615,10 +681,30 @@ export default function ModelsTab() {
rules={[{ required: true, message: '请选择供应商' }]}
>
<Select
placeholder="选择供应商(请先添加供应商)"
options={providers.map((p) => ({ label: p.name, value: p.id }))}
loading={providerOptionsLoading}
placeholder={
selectedFormCategory
? `选择支持${categoryLabelMap[selectedFormCategory]}的供应商`
: '选择供应商(请先添加供应商)'
}
options={providerSelectOptions}
notFoundContent={
providerOptionsLoading
? '加载中…'
: selectedFormCategory
? '暂无支持该类别的供应商'
: '暂无供应商'
}
/>
</Form.Item>
{unsupportedProviderWarning && (
<Alert
type="warning"
showIcon
className="mb-4"
message={unsupportedProviderWarning}
/>
)}
<Form.Item name="params" label="参数(JSON)">
<Input.TextArea rows={3} placeholder='{"max_tokens": 4096, "temperature": 0.7}' />
</Form.Item>