"""Interface-definition driven model resource registry APIs.""" import uuid from db.models import ( AssistantModelBinding, InterfaceDefinition, KnowledgeBase, ModelResource, ) from db.session import get_session from fastapi import APIRouter, Depends, HTTPException, Query from schemas import ( InterfaceDefinitionOut, ModelResourceOut, ModelResourceTestResult, ModelResourceUpsert, ) from services.interface_catalog import validate_fields from services.masking import mask_secrets, merge_secrets from services.model_resource_tester import test_model_resource from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession router = APIRouter(prefix="/api", tags=["model-registry"]) def _definition_dict(row: InterfaceDefinition) -> dict: return { "interface_type": row.interface_type, "name": row.name, "capability": row.capability, "fields": (row.field_schema or {}).get("fields", []), } def _definition_out(row: InterfaceDefinition) -> InterfaceDefinitionOut: return InterfaceDefinitionOut( interface_type=row.interface_type, name=row.name, capability=row.capability, # type: ignore[arg-type] field_schema=row.field_schema or {}, enabled=row.enabled, version=row.version, ) def _resource_out(row: ModelResource) -> ModelResourceOut: return ModelResourceOut( id=row.id, name=row.name, capability=row.capability, # type: ignore[arg-type] interface_type=row.interface_type, values=row.values or {}, secrets=mask_secrets(row.secrets or {}), support_image_input=row.support_image_input, enabled=row.enabled, is_default=row.is_default, updated_at=row.updated_at.isoformat() if row.updated_at else None, ) async def _commit_resource( session: AsyncSession, row: ModelResource ) -> ModelResourceOut: """Commit and reload server-generated fields before serializing the row.""" await session.commit() await session.refresh(row) return _resource_out(row) async def _definition( session: AsyncSession, interface_type: str ) -> InterfaceDefinition: row = await session.get(InterfaceDefinition, interface_type) if not row or not row.enabled: raise HTTPException(400, f"接口类型不可用: {interface_type}") return row async def _validate( session: AsyncSession, body: ModelResourceUpsert, stored_secrets: dict | None = None, ) -> tuple[InterfaceDefinition, dict]: definition = await _definition(session, body.interface_type) secrets = merge_secrets(body.secrets, stored_secrets or {}) try: validate_fields(_definition_dict(definition), body.values, secrets) except ValueError as exc: raise HTTPException(422, str(exc)) from exc return definition, secrets async def _clear_incompatible_references( session: AsyncSession, resource: ModelResource, capability: str ) -> None: if capability == resource.capability: return await session.execute( delete(AssistantModelBinding).where( AssistantModelBinding.model_resource_id == resource.id ) ) await session.execute( update(KnowledgeBase) .where(KnowledgeBase.embedding_model_resource_id == resource.id) .values(embedding_model_resource_id=None) ) @router.get("/interface-definitions", response_model=list[InterfaceDefinitionOut]) async def list_interface_definitions( capability: str | None = Query(default=None), session: AsyncSession = Depends(get_session), ): stmt = select(InterfaceDefinition).where(InterfaceDefinition.enabled.is_(True)) if capability: stmt = stmt.where(InterfaceDefinition.capability == capability) rows = (await session.execute(stmt.order_by(InterfaceDefinition.capability))).scalars().all() return [_definition_out(row) for row in rows] @router.get("/model-resources", response_model=list[ModelResourceOut]) async def list_model_resources(session: AsyncSession = Depends(get_session)): rows = ( await session.execute(select(ModelResource).order_by(ModelResource.capability)) ).scalars().all() return [_resource_out(row) for row in rows] @router.post("/model-resources", response_model=ModelResourceOut) async def create_model_resource( body: ModelResourceUpsert, session: AsyncSession = Depends(get_session) ): definition, secrets = await _validate(session, body) row = ModelResource( id=f"model_{uuid.uuid4().hex[:12]}", name=body.name, capability=definition.capability, interface_type=definition.interface_type, values=body.values, secrets=secrets, support_image_input=body.support_image_input if definition.capability == "LLM" else False, enabled=body.enabled, is_default=body.is_default, ) session.add(row) if row.is_default: await session.execute( update(ModelResource) .where(ModelResource.capability == row.capability, ModelResource.id != row.id) .values(is_default=False) ) return await _commit_resource(session, row) @router.post("/model-resources/test", response_model=ModelResourceTestResult) async def test_new_model_resource( body: ModelResourceUpsert, session: AsyncSession = Depends(get_session) ): definition, secrets = await _validate(session, body) return await test_model_resource( definition.interface_type, definition.capability, body.values, secrets, ) @router.post( "/model-resources/{resource_id}/test", response_model=ModelResourceTestResult ) async def test_saved_model_resource( resource_id: str, body: ModelResourceUpsert, session: AsyncSession = Depends(get_session), ): row = await session.get(ModelResource, resource_id) if not row: raise HTTPException(404, "模型资源不存在") definition, secrets = await _validate(session, body, row.secrets or {}) return await test_model_resource( definition.interface_type, definition.capability, body.values, secrets, ) @router.post("/model-resources/{resource_id}/duplicate", response_model=ModelResourceOut) async def duplicate_model_resource( resource_id: str, session: AsyncSession = Depends(get_session) ): source = await session.get(ModelResource, resource_id) if not source: raise HTTPException(404, "模型资源不存在") row = ModelResource( id=f"model_{uuid.uuid4().hex[:12]}", name=f"{source.name} 副本", capability=source.capability, interface_type=source.interface_type, values=dict(source.values or {}), secrets=dict(source.secrets or {}), support_image_input=source.support_image_input, enabled=source.enabled, is_default=False, ) session.add(row) return await _commit_resource(session, row) @router.put("/model-resources/{resource_id}", response_model=ModelResourceOut) async def update_model_resource( resource_id: str, body: ModelResourceUpsert, session: AsyncSession = Depends(get_session), ): row = await session.get(ModelResource, resource_id) if not row: raise HTTPException(404, "模型资源不存在") definition, secrets = await _validate(session, body, row.secrets or {}) await _clear_incompatible_references(session, row, definition.capability) row.name = body.name row.capability = definition.capability row.interface_type = definition.interface_type row.values = body.values row.secrets = secrets row.support_image_input = ( body.support_image_input if definition.capability == "LLM" else False ) row.enabled = body.enabled row.is_default = body.is_default if row.is_default: await session.execute( update(ModelResource) .where(ModelResource.capability == row.capability, ModelResource.id != row.id) .values(is_default=False) ) return await _commit_resource(session, row) @router.delete("/model-resources/{resource_id}") async def delete_model_resource( resource_id: str, session: AsyncSession = Depends(get_session) ): row = await session.get(ModelResource, resource_id) if not row: raise HTTPException(404, "模型资源不存在") in_use = ( await session.execute( select(AssistantModelBinding.assistant_id) .where(AssistantModelBinding.model_resource_id == resource_id) .limit(1) ) ).scalar_one_or_none() if in_use: raise HTTPException(409, "该模型资源仍被助手引用") await session.delete(row) await session.commit() return {"ok": True}