From 0b710a35dcd24b74b4a20117d57e43351e146601 Mon Sep 17 00:00:00 2001 From: extreme Date: Tue, 14 Apr 2026 11:05:21 +0800 Subject: [PATCH] =?UTF-8?q?[feat]=20=E6=B7=BB=E5=8A=A0=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=92=8C=E6=9B=B4=E6=96=B0=E6=97=B6=E5=AF=B9?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E7=B1=BB=E5=88=AB=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=9A=84=E9=AA=8C=E8=AF=81=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=BE=9B=E5=BA=94=E5=95=86=E9=80=89=E6=8B=A9=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/llm/manage.py | 42 ++++++++- backend/tests/test_llm_manage.py | 73 ++++++++++++++ front/src/pages/aiStudio/models/ModelsTab.tsx | 94 ++++++++++++++++++- 3 files changed, 202 insertions(+), 7 deletions(-) diff --git a/backend/app/services/llm/manage.py b/backend/app/services/llm/manage.py index 5f5c27f..90fe203 100644 --- a/backend/app/services/llm/manage.py +++ b/backend/app/services/llm/manage.py @@ -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}", + ) diff --git a/backend/tests/test_llm_manage.py b/backend/tests/test_llm_manage.py index 72fe9f5..621bde2 100644 --- a/backend/tests/test_llm_manage.py +++ b/backend/tests/test_llm_manage.py @@ -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() diff --git a/front/src/pages/aiStudio/models/ModelsTab.tsx b/front/src/pages/aiStudio/models/ModelsTab.tsx index 696d6bd..3679565 100644 --- a/front/src/pages/aiStudio/models/ModelsTab.tsx +++ b/front/src/pages/aiStudio/models/ModelsTab.tsx @@ -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([]) + const [supportedProviders, setSupportedProviders] = useState([]) const [models, setModels] = useState([]) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') @@ -56,14 +63,16 @@ export default function ModelsTab() { const [categoryFilter, setCategoryFilter] = useState(null) const [modelModalOpen, setModelModalOpen] = useState(false) const [modelEditing, setModelEditing] = useState(null) + const [providerOptionsLoading, setProviderOptionsLoading] = useState(true) const [form] = Form.useForm() + const selectedFormCategory = Form.useWatch('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: '请选择供应商' }]} >