274 lines
9.4 KiB
Python
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
|