dograh/api/routes/tool.py
Abhishek 3e55af9256
feat: user defined custom tools as part of workflow execution (#94)
* feat: add custom tools functionality

* Show tools in nodes

* integrate tool calling with pipeline engine
2026-01-02 13:11:02 +05:30

336 lines
9.4 KiB
Python

"""API routes for managing tools."""
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from api.db import db_client
from api.db.models import UserModel
from api.enums import ToolCategory, ToolStatus
from api.services.auth.depends import get_user
router = APIRouter(prefix="/tools")
# Request/Response schemas
class ToolParameter(BaseModel):
"""A parameter that the tool accepts."""
name: str = Field(description="Parameter name (used as key in request body)")
type: str = Field(description="Parameter type: string, number, or boolean")
description: str = Field(description="Description of what this parameter is for")
required: bool = Field(
default=True, description="Whether this parameter is required"
)
class HttpApiConfig(BaseModel):
"""Configuration for HTTP API tools."""
method: str = Field(description="HTTP method (GET, POST, PUT, PATCH, DELETE)")
url: str = Field(description="Target URL")
headers: Optional[Dict[str, str]] = Field(
default=None, description="Static headers to include"
)
credential_uuid: Optional[str] = Field(
default=None, description="Reference to ExternalCredentialModel for auth"
)
parameters: Optional[List[ToolParameter]] = Field(
default=None, description="Parameters that the tool accepts from LLM"
)
timeout_ms: Optional[int] = Field(
default=5000, description="Request timeout in milliseconds"
)
class ToolDefinition(BaseModel):
"""Tool definition schema."""
schema_version: int = Field(
default=1, description="Schema version for compatibility"
)
type: str = Field(description="Tool type (http_api)")
config: HttpApiConfig = Field(description="Tool configuration")
class CreateToolRequest(BaseModel):
"""Request schema for creating a tool."""
name: str = Field(max_length=255)
description: Optional[str] = None
category: str = Field(default=ToolCategory.HTTP_API.value)
icon: Optional[str] = Field(default="globe", max_length=50)
icon_color: Optional[str] = Field(default="#3B82F6", max_length=7)
definition: ToolDefinition
class UpdateToolRequest(BaseModel):
"""Request schema for updating a tool."""
name: Optional[str] = Field(default=None, max_length=255)
description: Optional[str] = None
icon: Optional[str] = Field(default=None, max_length=50)
icon_color: Optional[str] = Field(default=None, max_length=7)
definition: Optional[ToolDefinition] = None
status: Optional[str] = None
class CreatedByResponse(BaseModel):
"""Response schema for the user who created a tool."""
id: int
provider_id: str
class ToolResponse(BaseModel):
"""Response schema for a tool."""
id: int
tool_uuid: str
name: str
description: Optional[str]
category: str
icon: Optional[str]
icon_color: Optional[str]
status: str
definition: Dict[str, Any]
created_at: datetime
updated_at: Optional[datetime]
created_by: Optional[CreatedByResponse] = None
class Config:
from_attributes = True
def build_tool_response(tool, include_created_by: bool = False) -> ToolResponse:
"""Build a response from a tool model."""
created_by = None
if include_created_by and tool.created_by_user:
created_by = CreatedByResponse(
id=tool.created_by_user.id,
provider_id=tool.created_by_user.provider_id,
)
return ToolResponse(
id=tool.id,
tool_uuid=tool.tool_uuid,
name=tool.name,
description=tool.description,
category=tool.category,
icon=tool.icon,
icon_color=tool.icon_color,
status=tool.status,
definition=tool.definition,
created_at=tool.created_at,
updated_at=tool.updated_at,
created_by=created_by,
)
def validate_category(category: str) -> None:
"""Validate that the category is valid."""
valid_categories = [c.value for c in ToolCategory]
if category not in valid_categories:
raise HTTPException(
status_code=400,
detail=f"Invalid category '{category}'. Must be one of: {', '.join(valid_categories)}",
)
def validate_status(status: str) -> None:
"""Validate that the status is valid."""
valid_statuses = [s.value for s in ToolStatus]
if status not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"Invalid status '{status}'. Must be one of: {', '.join(valid_statuses)}",
)
@router.get("/")
async def list_tools(
status: Optional[str] = None,
category: Optional[str] = None,
user: UserModel = Depends(get_user),
) -> List[ToolResponse]:
"""
List all tools for the user's organization.
Args:
status: Optional filter by status (active, archived, draft)
category: Optional filter by category (http_api, native, integration)
Returns:
List of tools
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
if status:
validate_status(status)
if category:
validate_category(category)
tools = await db_client.get_tools_for_organization(
user.selected_organization_id,
status=status,
category=category,
)
return [build_tool_response(tool) for tool in tools]
@router.post("/")
async def create_tool(
request: CreateToolRequest,
user: UserModel = Depends(get_user),
) -> ToolResponse:
"""
Create a new tool.
Args:
request: The tool creation request
Returns:
The created tool
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
validate_category(request.category)
try:
tool = await db_client.create_tool(
organization_id=user.selected_organization_id,
user_id=user.id,
name=request.name,
definition=request.definition.model_dump(),
category=request.category,
description=request.description,
icon=request.icon,
icon_color=request.icon_color,
)
return build_tool_response(tool)
except Exception as e:
if "unique_org_tool_name" in str(e):
raise HTTPException(
status_code=409,
detail=f"A tool with the name '{request.name}' already exists",
)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{tool_uuid}")
async def get_tool(
tool_uuid: str,
user: UserModel = Depends(get_user),
) -> ToolResponse:
"""
Get a specific tool by UUID.
Args:
tool_uuid: The UUID of the tool
Returns:
The tool
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
tool = await db_client.get_tool_by_uuid(
tool_uuid, user.selected_organization_id, include_archived=True
)
if not tool:
raise HTTPException(status_code=404, detail="Tool not found")
return build_tool_response(tool, include_created_by=True)
@router.put("/{tool_uuid}")
async def update_tool(
tool_uuid: str,
request: UpdateToolRequest,
user: UserModel = Depends(get_user),
) -> ToolResponse:
"""
Update a tool.
Args:
tool_uuid: The UUID of the tool to update
request: The update request
Returns:
The updated tool
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
if request.status:
validate_status(request.status)
try:
tool = await db_client.update_tool(
tool_uuid=tool_uuid,
organization_id=user.selected_organization_id,
name=request.name,
description=request.description,
definition=request.definition.model_dump() if request.definition else None,
icon=request.icon,
icon_color=request.icon_color,
status=request.status,
)
if not tool:
raise HTTPException(status_code=404, detail="Tool not found")
return build_tool_response(tool, include_created_by=True)
except HTTPException:
raise
except Exception as e:
if "unique_org_tool_name" in str(e):
raise HTTPException(
status_code=409,
detail=f"A tool with the name '{request.name}' already exists",
)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{tool_uuid}")
async def delete_tool(
tool_uuid: str,
user: UserModel = Depends(get_user),
) -> dict:
"""
Archive (soft delete) a tool.
Args:
tool_uuid: The UUID of the tool to delete
Returns:
Success message
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
deleted = await db_client.archive_tool(tool_uuid, user.selected_organization_id)
if not deleted:
raise HTTPException(status_code=404, detail="Tool not found")
return {"status": "archived", "tool_uuid": tool_uuid}