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