[feat] 添加模型创建和更新时对供应商类别支持的验证,增强前端供应商选择功能
This commit is contained in:
1 parent
5e7d40da9a
commit
0b710a35dc
3 files changed
+202
-7
No files matched your search
@@ -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}",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user