Files
2026-07-27 19:02:37 +08:00

274 lines
9.4 KiB
Python

from django.db import transaction
from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.agents.models import AgentRun
from apps.modelhub.crypto import encrypt_secret
from apps.skills.services import SkillValidationError
from apps.tools.models import (
ToolAuthorization,
ToolCall,
ToolProvider,
UnifiedTool,
)
from apps.tools.schemas import (
ToolAuthorizationIn,
ToolAuthorizationOut,
ToolCallIn,
ToolCallOut,
ToolConfirmationIn,
ToolProviderIn,
ToolProviderOut,
ToolProviderUpdateIn,
UnifiedToolOut,
UnifiedToolUpdateIn,
)
from apps.tools.services import (
ToolExecutionError,
decide_tool_confirmation,
request_tool_call,
sync_mcp_tools,
)
router = Router(tags=["tools"])
def serialize_provider(provider: ToolProvider) -> dict:
return {
"id": provider.id,
"name": provider.name,
"provider_type": provider.provider_type,
"base_url": provider.base_url,
"credential_set": bool(provider.token_encrypted),
"enabled": provider.enabled,
"timeout_seconds": provider.timeout_seconds,
"created_at": provider.created_at,
"updated_at": provider.updated_at,
}
def serialize_call(call: ToolCall) -> dict:
confirmation = getattr(call, "confirmation", None)
return {
"public_id": call.public_id,
"tool_code": call.tool.code,
"requested_by_id": call.requested_by_id,
"requested_by_name": (
call.requested_by.get_full_name() or call.requested_by.username
if call.requested_by
else ""
),
"status": call.status,
"input_payload": call.input_payload,
"output_payload": call.output_payload,
"authorization_snapshot": call.authorization_snapshot,
"risk_snapshot": call.risk_snapshot,
"error_code": call.error_code,
"error_message": call.error_message,
"confirmation_status": confirmation.status if confirmation else None,
"confirmation_reason": confirmation.reason if confirmation else "",
"created_at": call.created_at,
"started_at": call.started_at,
"finished_at": call.finished_at,
}
@router.get("/providers", response=list[ToolProviderOut])
def list_tool_providers(request):
require_permission(request, "tool.manage")
return [serialize_provider(item) for item in ToolProvider.objects.all()]
@router.post("/providers", response=ToolProviderOut)
def create_tool_provider(request, payload: ToolProviderIn):
require_permission(request, "tool.manage")
if payload.provider_type == "mcp_http" and not payload.base_url:
raise HttpError(400, "HTTP MCP 必须配置服务地址")
provider = ToolProvider.objects.create(
name=payload.name,
provider_type=payload.provider_type,
base_url=payload.base_url,
token_encrypted=encrypt_secret(payload.token) if payload.token else "",
enabled=payload.enabled,
timeout_seconds=min(max(payload.timeout_seconds, 1), 300),
created_by=request.user,
)
return serialize_provider(provider)
@router.patch("/providers/{provider_id}", response=ToolProviderOut)
def update_tool_provider(
request,
provider_id: int,
payload: ToolProviderUpdateIn,
):
require_permission(request, "tool.manage")
provider = ToolProvider.objects.filter(id=provider_id).first()
if provider is None:
raise HttpError(404, "工具服务不存在")
values = payload.dict(exclude_unset=True)
if "base_url" in values and provider.provider_type == "mcp_http":
if not values["base_url"]:
raise HttpError(400, "HTTP MCP 必须配置服务地址")
token = values.pop("token", None)
if token:
provider.token_encrypted = encrypt_secret(token)
if "timeout_seconds" in values:
values["timeout_seconds"] = min(max(values["timeout_seconds"], 1), 300)
for field, value in values.items():
setattr(provider, field, value)
provider.save()
return serialize_provider(provider)
@router.post("/providers/{provider_id}/sync", response=list[UnifiedToolOut])
def sync_tool_provider(request, provider_id: int):
require_permission(request, "tool.manage")
provider = ToolProvider.objects.filter(id=provider_id).first()
if provider is None:
raise HttpError(404, "工具服务不存在")
try:
return sync_mcp_tools(provider)
except ToolExecutionError as error:
raise HttpError(400, str(error)) from error
@router.get("", response=list[UnifiedToolOut])
def list_tools(request):
require_permission(request, "tool.view")
return UnifiedTool.objects.select_related("provider").all()
@router.get(
"/{tool_id}/authorizations",
response=list[ToolAuthorizationOut],
)
def list_tool_authorizations(request, tool_id: int):
require_permission(request, "tool.manage")
if not UnifiedTool.objects.filter(id=tool_id).exists():
raise HttpError(404, "工具不存在")
return ToolAuthorization.objects.filter(tool_id=tool_id)
@router.post(
"/{tool_id}/authorizations",
response=ToolAuthorizationOut,
)
def create_tool_authorization(request, tool_id: int, payload: ToolAuthorizationIn):
require_permission(request, "tool.manage")
if payload.principal_type not in {"user", "group", "agent"}:
raise HttpError(400, "授权主体必须是 user、group 或 agent")
tool = UnifiedTool.objects.filter(id=tool_id).first()
if tool is None:
raise HttpError(404, "工具不存在")
authorization, _ = ToolAuthorization.objects.update_or_create(
tool=tool,
principal_type=payload.principal_type,
principal_id=payload.principal_id,
defaults={"allowed": payload.allowed, "created_by": request.user},
)
return authorization
@router.get("/calls", response=list[ToolCallOut])
def list_tool_calls(request, status: str = "", tool_code: str = ""):
require_permission(request, "tool.invoke")
queryset = ToolCall.objects.select_related(
"tool",
"requested_by",
"confirmation",
)
can_confirm = request.user.is_superuser or request.user.has_perm(
"system.tool.confirm"
)
if not can_confirm:
queryset = queryset.filter(requested_by=request.user)
if status:
queryset = queryset.filter(status=status)
if tool_code:
queryset = queryset.filter(tool__code=tool_code)
return [serialize_call(call) for call in queryset[:100]]
@router.post("/calls", response=ToolCallOut)
def create_tool_call(request, payload: ToolCallIn):
require_permission(request, "tool.invoke")
tool = UnifiedTool.objects.filter(code=payload.tool_code).first()
if tool is None:
raise HttpError(404, "工具不存在")
run = None
if payload.agent_run_id:
run = AgentRun.objects.filter(public_id=payload.agent_run_id).first()
if run is None:
raise HttpError(404, "Agent 运行不存在")
try:
with transaction.atomic():
call = request_tool_call(
tool,
payload.input,
user=request.user,
run=run,
idempotency_key=payload.idempotency_key,
)
except (ToolExecutionError, SkillValidationError) as error:
raise HttpError(400, str(error)) from error
call = ToolCall.objects.select_related("tool", "confirmation").get(id=call.id)
return serialize_call(call)
@router.get("/calls/{public_id}", response=ToolCallOut)
def get_tool_call(request, public_id: str):
require_permission(request, "tool.invoke")
call = ToolCall.objects.select_related("tool", "confirmation").filter(
public_id=public_id
).first()
if call is None:
raise HttpError(404, "工具调用不存在")
if not request.user.is_superuser and call.requested_by_id != request.user.id:
require_permission(request, "tool.confirm")
return serialize_call(call)
@router.post("/calls/{public_id}/confirmation", response=ToolCallOut)
def confirm_tool_call(request, public_id: str, payload: ToolConfirmationIn):
require_permission(request, "tool.confirm")
call = ToolCall.objects.select_related("tool", "confirmation").filter(
public_id=public_id
).first()
if call is None:
raise HttpError(404, "工具调用不存在")
try:
with transaction.atomic():
decide_tool_confirmation(
call,
reviewer=request.user,
decision=payload.decision,
reason=payload.reason,
)
except ToolExecutionError as error:
raise HttpError(400, str(error)) from error
call.refresh_from_db()
return serialize_call(
ToolCall.objects.select_related("tool", "confirmation").get(id=call.id)
)
@router.patch("/{tool_id}", response=UnifiedToolOut)
def update_tool(request, tool_id: int, payload: UnifiedToolUpdateIn):
require_permission(request, "tool.manage")
tool = UnifiedTool.objects.filter(id=tool_id).first()
if tool is None:
raise HttpError(404, "工具不存在")
values = payload.dict(exclude_unset=True)
if "risk_level" in values and values["risk_level"] not in {
"read_only",
"low_write",
"high_risk",
}:
raise HttpError(400, "未知工具风险等级")
for field, value in values.items():
setattr(tool, field, value)
tool.save()
return tool