Refactor backend to support interface-definition driven model resources

- Introduce a new model structure for managing interface definitions and model resources, enhancing the backend's capability to handle various service integrations.
- Update the Makefile to reflect changes in database seeding and resource management commands.
- Remove the deprecated credentials management routes and replace them with a unified model registry API.
- Modify existing routes and schemas to align with the new model structure, ensuring seamless integration with the frontend.
- Enhance database seeding scripts to populate new model resources and their configurations.
- Update README documentation to reflect the new architecture and usage instructions for model resources and interface definitions.
This commit is contained in:
Xin Wang
2026-06-14 19:36:12 +08:00
parent e25dfd4003
commit 90e3e8a0c0
32 changed files with 2577 additions and 1765 deletions

View File

@@ -1,12 +1,8 @@
"""助手 CRUD。前端「助手列表 / 创建 / 编辑」对接这里。
模型/KB 以 FK 引用注册表;瘦类型字段直接是真列。外部类型(dify/fastgpt/opencode)的
api_key 是私有密钥,读时打码、写时哨兵(列级,复用 services/masking,与凭证表一致)。
"""
"""Assistant CRUD backed by capability-to-model-resource bindings."""
import uuid
from db.models import Assistant
from db.models import Assistant, AssistantModelBinding, ModelResource
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import AssistantOut, AssistantUpsert
@@ -15,27 +11,62 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/assistants", tags=["assistants"])
CAPABILITIES = ("LLM", "ASR", "TTS", "Realtime", "Embedding")
def _to_out(a: Assistant) -> AssistantOut:
async def _sync_bindings(
session: AsyncSession, assistant_id: str, resource_ids: dict[str, str]
) -> None:
for capability in CAPABILITIES:
resource_id = resource_ids.get(capability)
binding = await session.get(AssistantModelBinding, (assistant_id, capability))
if not resource_id:
if binding:
await session.delete(binding)
continue
resource = await session.get(ModelResource, resource_id)
if not resource or resource.capability != capability:
raise HTTPException(400, f"{capability} 绑定必须引用同能力的模型资源")
if binding:
binding.model_resource_id = resource_id
else:
session.add(
AssistantModelBinding(
assistant_id=assistant_id,
capability=capability,
model_resource_id=resource_id,
config={},
)
)
async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, str]:
bindings = (
await session.execute(
select(AssistantModelBinding).where(
AssistantModelBinding.assistant_id == assistant_id
)
)
).scalars().all()
return {binding.capability: binding.model_resource_id for binding in bindings}
async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
return AssistantOut(
id=a.id,
name=a.name,
type=a.type, # type: ignore[arg-type]
runtime_mode=a.runtime_mode, # type: ignore[arg-type]
greeting=a.greeting,
enable_interrupt=a.enable_interrupt,
llm_credential_id=a.llm_credential_id,
asr_credential_id=a.asr_credential_id,
tts_credential_id=a.tts_credential_id,
realtime_credential_id=a.realtime_credential_id,
knowledge_base_id=a.knowledge_base_id,
prompt=a.prompt,
api_url=a.api_url,
api_key=mask(a.api_key), # 仅外部类型有值;空串 mask 仍是空串
app_id=a.app_id,
graph=a.graph or {},
updated_at=a.updated_at.isoformat() if a.updated_at else None,
id=assistant.id,
name=assistant.name,
type=assistant.type, # type: ignore[arg-type]
runtime_mode=assistant.runtime_mode, # type: ignore[arg-type]
greeting=assistant.greeting,
enable_interrupt=assistant.enable_interrupt,
model_resource_ids=await _resource_ids(session, assistant.id),
knowledge_base_id=assistant.knowledge_base_id,
prompt=assistant.prompt,
api_url=assistant.api_url,
api_key=mask(assistant.api_key),
app_id=assistant.app_id,
graph=assistant.graph or {},
updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
)
@@ -44,60 +75,61 @@ async def list_assistants(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(select(Assistant).order_by(Assistant.updated_at.desc()))
).scalars().all()
return [_to_out(a) for a in rows]
return [await _to_out(session, assistant) for assistant in rows]
@router.post("", response_model=AssistantOut)
async def create_assistant(
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
):
a = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **body.model_dump())
session.add(a)
data = body.model_dump()
resource_ids = data.pop("model_resource_ids")
assistant = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **data)
session.add(assistant)
await session.flush()
await _sync_bindings(session, assistant.id, resource_ids)
await session.commit()
await session.refresh(a)
return _to_out(a)
await session.refresh(assistant)
return await _to_out(session, assistant)
@router.get("/{assistant_id}", response_model=AssistantOut)
async def get_assistant(
assistant_id: str, session: AsyncSession = Depends(get_session)
):
a = await session.get(Assistant, assistant_id)
if not a:
assistant = await session.get(Assistant, assistant_id)
if not assistant:
raise HTTPException(404, "助手不存在")
return _to_out(a)
return await _to_out(session, assistant)
@router.post("/{assistant_id}/duplicate", response_model=AssistantOut)
async def duplicate_assistant(
assistant_id: str, session: AsyncSession = Depends(get_session)
):
"""服务端整行复制:含真实 api_key,DB→DB,密钥不经过浏览器,副本可直接用。"""
src = await session.get(Assistant, assistant_id)
if not src:
source = await session.get(Assistant, assistant_id)
if not source:
raise HTTPException(404, "助手不存在")
a = Assistant(
assistant = Assistant(
id=f"asst_{uuid.uuid4().hex[:12]}",
name=f"{src.name} 副本",
type=src.type,
runtime_mode=src.runtime_mode,
greeting=src.greeting,
enable_interrupt=src.enable_interrupt,
llm_credential_id=src.llm_credential_id,
asr_credential_id=src.asr_credential_id,
tts_credential_id=src.tts_credential_id,
realtime_credential_id=src.realtime_credential_id,
knowledge_base_id=src.knowledge_base_id,
prompt=src.prompt,
api_url=src.api_url,
api_key=src.api_key, # 真 key,DB→DB
app_id=src.app_id,
graph=dict(src.graph or {}), # 浅拷贝,避免与源行共享同一 dict
name=f"{source.name} 副本",
type=source.type,
runtime_mode=source.runtime_mode,
greeting=source.greeting,
enable_interrupt=source.enable_interrupt,
knowledge_base_id=source.knowledge_base_id,
prompt=source.prompt,
api_url=source.api_url,
api_key=source.api_key,
app_id=source.app_id,
graph=dict(source.graph or {}),
)
session.add(a)
session.add(assistant)
await session.flush()
await _sync_bindings(session, assistant.id, await _resource_ids(session, source.id))
await session.commit()
await session.refresh(a)
return _to_out(a)
await session.refresh(assistant)
return await _to_out(session, assistant)
@router.put("/{assistant_id}", response_model=AssistantOut)
@@ -106,26 +138,27 @@ async def update_assistant(
body: AssistantUpsert,
session: AsyncSession = Depends(get_session),
):
a = await session.get(Assistant, assistant_id)
if not a:
assistant = await session.get(Assistant, assistant_id)
if not assistant:
raise HTTPException(404, "助手不存在")
data = body.model_dump()
# 写时哨兵(列级):回传打码/空 api_key → 保留旧 key
data["api_key"] = resolve_incoming_key(data["api_key"], a.api_key)
for k, v in data.items():
setattr(a, k, v)
resource_ids = data.pop("model_resource_ids")
data["api_key"] = resolve_incoming_key(data["api_key"], assistant.api_key)
for key, value in data.items():
setattr(assistant, key, value)
await _sync_bindings(session, assistant.id, resource_ids)
await session.commit()
await session.refresh(a)
return _to_out(a)
await session.refresh(assistant)
return await _to_out(session, assistant)
@router.delete("/{assistant_id}")
async def delete_assistant(
assistant_id: str, session: AsyncSession = Depends(get_session)
):
a = await session.get(Assistant, assistant_id)
if not a:
assistant = await session.get(Assistant, assistant_id)
if not assistant:
raise HTTPException(404, "助手不存在")
await session.delete(a)
await session.delete(assistant)
await session.commit()
return {"ok": True}

View File

@@ -1,190 +0,0 @@
"""模型资源凭证 CRUD。前端 ComponentsModelsPage 对接这里。
字段对齐前端 ModelResource。读:api_key 打码;写:占位符表示不改(写时哨兵)。
设默认时清掉同 type 其它默认。
"""
import uuid
from db.models import ProviderCredential
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import (
CredentialOut,
CredentialTestRequest,
CredentialTestResult,
CredentialUpsert,
)
from services.credential_tester import test_openai_credential, test_xfyun_credential
from services.masking import mask, resolve_incoming_key
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/credentials", tags=["credentials"])
def _to_out(c: ProviderCredential) -> CredentialOut:
return CredentialOut(
id=c.id,
name=c.name,
model_id=c.model_id,
type=c.type,
interface_type=c.interface_type,
api_url=c.api_url,
api_key=mask(c.api_key), # 永远打码
voice=c.voice,
speed=c.speed,
language=c.language,
is_default=c.is_default,
)
async def _clear_other_defaults(session: AsyncSession, type_: str, keep_id: str):
await session.execute(
update(ProviderCredential)
.where(ProviderCredential.type == type_, ProviderCredential.id != keep_id)
.values(is_default=False)
)
@router.get("", response_model=list[CredentialOut])
async def list_credentials(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(
select(ProviderCredential).order_by(ProviderCredential.type)
)
).scalars().all()
return [_to_out(c) for c in rows]
@router.post("", response_model=CredentialOut)
async def create_credential(
body: CredentialUpsert, session: AsyncSession = Depends(get_session)
):
c = ProviderCredential(
id=f"model_{uuid.uuid4().hex[:12]}",
name=body.name,
model_id=body.model_id,
type=body.type,
interface_type=body.interface_type,
api_url=body.api_url,
api_key=resolve_incoming_key(body.api_key, ""),
voice=body.voice,
speed=body.speed,
language=body.language,
is_default=body.is_default,
)
session.add(c)
if c.is_default:
await _clear_other_defaults(session, c.type, c.id)
await session.commit()
await session.refresh(c)
return _to_out(c)
@router.post("/test", response_model=CredentialTestResult)
async def test_new_credential(body: CredentialTestRequest):
if body.interface_type == "xfyun":
return test_xfyun_credential(body)
if body.interface_type != "openai":
return CredentialTestResult(
ok=False,
message="暂不支持该接口类型",
detail="当前仅支持 OpenAI 兼容接口测试",
)
if not body.api_key:
return CredentialTestResult(
ok=False,
message="缺少 API Key",
detail="测试新配置时需要输入 API Key",
)
return await test_openai_credential(body)
@router.post("/{cred_id}/test", response_model=CredentialTestResult)
async def test_saved_credential(
cred_id: str,
body: CredentialTestRequest,
session: AsyncSession = Depends(get_session),
):
c = await session.get(ProviderCredential, cred_id)
if not c:
raise HTTPException(404, "凭证不存在")
config = body.model_copy(
update={"api_key": resolve_incoming_key(body.api_key, c.api_key)}
)
if config.interface_type == "xfyun":
return test_xfyun_credential(config)
if config.interface_type != "openai":
return CredentialTestResult(
ok=False,
message="暂不支持该接口类型",
detail="当前仅支持 OpenAI 兼容接口测试",
)
return await test_openai_credential(config)
@router.post("/{cred_id}/duplicate", response_model=CredentialOut)
async def duplicate_credential(
cred_id: str, session: AsyncSession = Depends(get_session)
):
"""服务端整行复制:含真实 api_key,DB→DB,密钥不经浏览器。副本不继承默认标记。"""
src = await session.get(ProviderCredential, cred_id)
if not src:
raise HTTPException(404, "凭证不存在")
c = ProviderCredential(
id=f"model_{uuid.uuid4().hex[:12]}",
name=f"{src.name} 副本",
model_id=src.model_id,
type=src.type,
interface_type=src.interface_type,
api_url=src.api_url,
api_key=src.api_key, # 真 key,DB→DB
voice=src.voice,
speed=src.speed,
language=src.language,
is_default=False, # 副本不继承默认,避免抢走源的默认标记
)
session.add(c)
await session.commit()
await session.refresh(c)
return _to_out(c)
@router.put("/{cred_id}", response_model=CredentialOut)
async def update_credential(
cred_id: str,
body: CredentialUpsert,
session: AsyncSession = Depends(get_session),
):
c = await session.get(ProviderCredential, cred_id)
if not c:
raise HTTPException(404, "凭证不存在")
c.name = body.name
c.model_id = body.model_id
c.type = body.type
c.interface_type = body.interface_type
c.api_url = body.api_url
c.voice = body.voice
c.speed = body.speed
c.language = body.language
c.is_default = body.is_default
# 写时哨兵:打码占位符 → 保留旧 key
c.api_key = resolve_incoming_key(body.api_key, c.api_key)
if c.is_default:
await _clear_other_defaults(session, c.type, c.id)
await session.commit()
await session.refresh(c)
return _to_out(c)
@router.delete("/{cred_id}")
async def delete_credential(
cred_id: str, session: AsyncSession = Depends(get_session)
):
c = await session.get(ProviderCredential, cred_id)
if not c:
raise HTTPException(404, "凭证不存在")
await session.delete(c)
await session.commit()
return {"ok": True}

View File

@@ -1,12 +1,12 @@
"""知识库 CRUD。前端助手编辑页的"知识库"下拉对接这里。
KB 自身引用一个 Embedding 凭证(embeddingCredentialId)。被助手引用时禁止删除
KB 自身引用一个 Embedding 模型资源。被助手引用时禁止删除
(DB 层 ON DELETE RESTRICT),这里把外键冲突翻译成 409。
"""
import uuid
from db.models import KnowledgeBase
from db.models import KnowledgeBase, ModelResource
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import KnowledgeBaseOut, KnowledgeBaseUpsert
@@ -17,12 +17,22 @@ from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/knowledge-bases", tags=["knowledge-bases"])
async def _validate_embedding_resource(
session: AsyncSession, resource_id: str | None
) -> None:
if not resource_id:
return
resource = await session.get(ModelResource, resource_id)
if not resource or resource.capability != "Embedding":
raise HTTPException(400, "知识库必须引用 Embedding 模型资源")
def _to_out(kb: KnowledgeBase) -> KnowledgeBaseOut:
return KnowledgeBaseOut(
id=kb.id,
name=kb.name,
description=kb.description,
embedding_credential_id=kb.embedding_credential_id,
embedding_model_resource_id=kb.embedding_model_resource_id,
status=kb.status,
updated_at=kb.updated_at.isoformat() if kb.updated_at else None,
)
@@ -40,6 +50,7 @@ async def list_knowledge_bases(session: AsyncSession = Depends(get_session)):
async def create_knowledge_base(
body: KnowledgeBaseUpsert, session: AsyncSession = Depends(get_session)
):
await _validate_embedding_resource(session, body.embedding_model_resource_id)
kb = KnowledgeBase(id=f"kb_{uuid.uuid4().hex[:12]}", **body.model_dump())
session.add(kb)
await session.commit()
@@ -66,6 +77,7 @@ async def update_knowledge_base(
kb = await session.get(KnowledgeBase, kb_id)
if not kb:
raise HTTPException(404, "知识库不存在")
await _validate_embedding_resource(session, body.embedding_model_resource_id)
for k, v in body.model_dump().items():
setattr(kb, k, v)
await session.commit()

View File

@@ -0,0 +1,249 @@
"""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 {}),
enabled=row.enabled,
is_default=row.is_default,
updated_at=row.updated_at.isoformat() if row.updated_at else None,
)
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,
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)
)
await session.commit()
return _resource_out(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 {}),
enabled=source.enabled,
is_default=False,
)
session.add(row)
await session.commit()
return _resource_out(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.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)
)
await session.commit()
return _resource_out(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}