Files
AgentLab/backend/app/api/capabilities.py
T

217 lines
11 KiB
Python

"""能力登记与端点(BE-1):/api/capabilities、/api/endpoints。设计文档 §2.6、§3.9、§3.10。
权限(§3.12「MCP stdio、端点和凭据、能力治理、模型接入」那一行):搭建者只读,改动只给管理员。main.py 给整组挂
require_role("builder"),写接口在里面再要 admin。
- 能力列表每次都先同步一遍(sync_all 只改变了的行,几条查询):刚建的数据源、刚加的自定义工具马上看得到;
- 治理带 version 乐观锁:读到的是 v3、提交时已经是 v4 的回 409,界面重新读一遍再改;
- 端点的认证头只回掩码,提交掩码值表示保持原值;allow_intranet 只有管理员能开(写接口本身只给管理员)。
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import current_principal, ensure_role
from app.auth.audit import record
from app.capabilities import endpoints as ep
from app.capabilities import registry
from app.db.base import get_session
from app.db.models import Capability, CustomTool, Endpoint, McpServer
router = APIRouter(prefix="/api", tags=["capabilities"])
def capability_out(row: Capability) -> dict[str, Any]:
return {"name": row.name, "system": row.system, "title": row.title, "description": row.description,
"params_schema": row.params_schema or {}, "returns_schema": row.returns_schema, "tabular": row.tabular,
"effect_system": row.effect_system, "effect": row.effect, "approval": row.approval,
"unattended_write": row.unattended_write, "fields": row.fields or {}, "masked": list(row.masked or []),
"credential_mode": row.credential_mode, "endpoint_id": row.endpoint_id, "lookup": row.lookup,
"version": row.version, "missing": row.missing,
"synced_at": row.synced_at.isoformat() if row.synced_at else None, "updated_by": row.updated_by}
@router.get("/capabilities")
async def list_capabilities(system: str | None = None, effect: str | None = None, q: str | None = None,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
await registry.sync_all(session)
rows = list((await session.execute(select(Capability).order_by(Capability.system, Capability.name))).scalars())
if system:
rows = [r for r in rows if r.system == system]
if effect:
rows = [r for r in rows if registry.effective_effect(r) == effect]
if q:
needle = q.strip().lower()
rows = [r for r in rows if needle in r.name.lower() or needle in (r.title or "").lower()
or needle in (r.description or "").lower()]
return {"items": [capability_out(r) for r in rows]}
@router.post("/capabilities/sync")
async def sync_capabilities(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
return {"changed": await registry.sync_all(session)}
@router.get("/capabilities/{name:path}")
async def get_capability(name: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
await registry.ensure_synced(session, [name])
row = await session.get(Capability, name)
if row is None:
raise HTTPException(404, f"能力「{name}」不存在")
return capability_out(row)
class GovernanceIn(BaseModel):
version: int
model_config = {"extra": "allow"}
@router.put("/capabilities/{name:path}/governance")
async def govern_capability(name: str, payload: GovernanceIn, session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""改治理字段。只认 registry.GOVERNED_FIELDS 里的键,带上读到的 version;改一次 version 加一,写审计。"""
ensure_role("admin")
changes = {k: v for k, v in (payload.model_extra or {}).items() if k in registry.GOVERNED_FIELDS}
if not changes:
raise HTTPException(422, "没有可修改的治理字段")
principal = current_principal()
try:
row = await registry.govern(session, name, changes, version=payload.version, actor=principal.username,
verified=principal.verified)
except LookupError as e:
raise HTTPException(404, f"能力「{name}」不存在") from e
except registry.GovernanceError as e:
raise HTTPException(422, str(e)) from e
except registry.VersionConflict as e:
raise HTTPException(409, "这项能力的治理设置已被其他人修改,请刷新后再改") from e
return capability_out(row)
# ---------------------------------------------------------------- 端点
@router.get("/endpoints")
async def list_endpoints(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
rows = (await session.execute(select(Endpoint).order_by(Endpoint.name))).scalars()
return {"items": [ep.endpoint_out(r) for r in rows]}
class EndpointIn(BaseModel):
name: str = Field(min_length=1, max_length=100, pattern=r"^[^\s/]+$")
description: str = ""
environments: dict[str, Any] = Field(default_factory=dict)
idempotency_header: str = Field(default="Idempotency-Key", min_length=1, max_length=100)
timeout_s: int = Field(default=20, ge=1, le=300)
class EndpointPatch(BaseModel):
version: int
name: str | None = Field(default=None, min_length=1, max_length=100, pattern=r"^[^\s/]+$")
description: str | None = None
environments: dict[str, Any] | None = None
idempotency_header: str | None = Field(default=None, min_length=1, max_length=100)
timeout_s: int | None = Field(default=None, ge=1, le=300)
def _audit_view(envs: dict[str, Any]) -> dict[str, Any]:
"""审计里记的端点环境:地址和开关照记,认证头只记名字。"""
return {k: {"base_url": v.get("base_url"), "headers": sorted((v.get("headers") or {})),
"allow_intranet": v.get("allow_intranet"), "verify_tls": v.get("verify_tls")}
for k, v in (envs or {}).items()}
@router.post("/endpoints", status_code=201)
async def create_endpoint(payload: EndpointIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
ensure_role("admin")
if (await session.execute(select(Endpoint.id).where(Endpoint.name == payload.name))).first():
raise HTTPException(409, f"已存在名为「{payload.name}」的端点,请换一个名称")
try:
envs = ep.seal_environments(payload.environments, None)
except ep.EndpointError as e:
raise HTTPException(422, str(e)) from e
row = Endpoint(name=payload.name, description=payload.description, environments=envs,
idempotency_header=payload.idempotency_header, timeout_s=payload.timeout_s,
updated_by=current_principal().username)
session.add(row)
await session.flush()
await record(session, "endpoint.create", "endpoint", row.id, data={"name": row.name, "environments": _audit_view(envs)})
await session.commit()
await registry.refresh_cache(session)
return ep.endpoint_out(row)
@router.patch("/endpoints/{endpoint_id}")
async def update_endpoint(endpoint_id: str, payload: EndpointPatch, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
ensure_role("admin")
row = await session.get(Endpoint, endpoint_id)
if row is None:
raise HTTPException(404, "端点不存在,可能已被删除")
values: dict[str, Any] = {}
if payload.name is not None and payload.name != row.name:
if (await session.execute(select(Endpoint.id).where(Endpoint.name == payload.name))).first():
raise HTTPException(409, f"已存在名为「{payload.name}」的端点,请换一个名称")
values["name"] = payload.name
if payload.environments is not None:
try:
values["environments"] = ep.seal_environments(payload.environments, row.environments)
except ep.EndpointError as e:
raise HTTPException(422, str(e)) from e
for key in ("description", "idempotency_header", "timeout_s"):
if (value := getattr(payload, key)) is not None:
values[key] = value
before = {"name": row.name, "environments": _audit_view(row.environments)}
done = await session.execute(update(Endpoint).where(Endpoint.id == endpoint_id, Endpoint.version == payload.version)
.values(**values, version=payload.version + 1, updated_by=current_principal().username))
if done.rowcount == 0:
await session.rollback()
raise HTTPException(409, "这个端点已被其他人修改,请刷新后再改")
await record(session, "endpoint.update", "endpoint", endpoint_id, data={
"before": before, "after": {"name": values.get("name", row.name),
"environments": _audit_view(values.get("environments", row.environments))}})
await session.commit()
session.expire_all()
row = await session.get(Endpoint, endpoint_id)
assert row is not None
# 端点改了名字,绑定它的能力的「所属系统」跟着变;绑定它的 MCP 服务按新的地址、认证头重连
await registry.sync_all(session)
from app.tools.mcp_manager import mcp_manager
mcp_manager.invalidate(*(await session.execute(
select(McpServer.name).where(McpServer.endpoint_id == endpoint_id))).scalars())
return ep.endpoint_out(row)
@router.delete("/endpoints/{endpoint_id}", status_code=204)
async def delete_endpoint(endpoint_id: str, session: AsyncSession = Depends(get_session)) -> None:
ensure_role("admin")
row = await session.get(Endpoint, endpoint_id)
if row is None:
raise HTTPException(404, "端点不存在,可能已被删除")
tools = [t.name for t in (await session.execute(select(CustomTool))).scalars()
if ep.bound_endpoint_ref(t) in (row.id, row.name)]
servers = list((await session.execute(select(McpServer.name).where(McpServer.endpoint_id == row.id))).scalars())
if users := tools + [f"MCP 服务「{s}」" for s in servers]:
raise HTTPException(409, f"端点仍被使用:{'、'.join(users)}。请先解除绑定再删除")
await record(session, "endpoint.delete", "endpoint", row.id, data={"name": row.name})
await session.delete(row)
await session.commit()
await registry.refresh_cache(session)
@router.post("/endpoints/{endpoint_id}/test")
async def test_endpoint(endpoint_id: str, env: str = "prod", session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
ensure_role("admin")
row = await session.get(Endpoint, endpoint_id)
if row is None:
raise HTTPException(404, "端点不存在,可能已被删除")
if env not in ep.ENVIRONMENTS:
raise HTTPException(422, "环境只能是测试或生产")
return await ep.probe(row, env)