improve endpoint self-documentation, add explicit operation IDs

This commit is contained in:
Reinier van der Leer
2026-03-11 12:54:55 +01:00
parent 4cc25a5476
commit 42bd0a20fe
18 changed files with 527 additions and 288 deletions

View File

@@ -55,17 +55,31 @@ v2_app = FastAPI(
"name": "blocks",
"description": "Discover available building blocks",
},
{
"name": "search",
"description": "Cross-domain hybrid search across agents, blocks, and docs",
},
{
"name": "marketplace",
"description": "Browse agents and creators, manage submissions",
},
{
"name": "library",
"description": "Access your agent library and execute agents",
"description": (
"Manage your agent library (agents and presets), "
"execute agents, organize with folders"
),
},
{
"name": "presets",
"description": "Agent execution presets with webhook triggers",
},
{
"name": "runs",
"description": "Monitor execution runs and human-in-the-loop reviews",
"description": (
"Monitor, stop, delete, and share execution runs; "
"manage human-in-the-loop reviews"
),
},
{
"name": "credits",
@@ -73,11 +87,11 @@ v2_app = FastAPI(
},
{
"name": "integrations",
"description": "Manage OAuth credentials for external services",
"description": "List, create, and delete integration credentials",
},
{
"name": "files",
"description": "Upload files for agent input",
"description": "Upload, list, download, and delete workspace files",
},
],
)

View File

@@ -19,7 +19,7 @@ from .models import BlockInfo
logger = logging.getLogger(__name__)
blocks_router = APIRouter()
blocks_router = APIRouter(tags=["blocks"])
# ============================================================================
@@ -56,9 +56,10 @@ async def _get_cached_blocks() -> list[BlockInfo]:
@blocks_router.get(
path="",
summary="List blocks",
summary="List available blocks",
operation_id="listAvailableBlocks",
)
async def list_blocks(
async def list_available_blocks(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.READ_BLOCK)
),

View File

@@ -19,7 +19,7 @@ from .models import CreditBalance, CreditTransaction, CreditTransactionsResponse
logger = logging.getLogger(__name__)
credits_router = APIRouter()
credits_router = APIRouter(tags=["credits"])
# ============================================================================
@@ -30,6 +30,7 @@ credits_router = APIRouter()
@credits_router.get(
path="",
summary="Get credit balance",
operation_id="getCreditBalance",
)
async def get_balance(
auth: APIAuthorizationInfo = Security(
@@ -45,7 +46,8 @@ async def get_balance(
@credits_router.get(
path="/transactions",
summary="Get transaction history",
summary="Get credit transaction history",
operation_id="listCreditTransactions",
)
async def get_transactions(
page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),

View File

@@ -1,7 +1,7 @@
"""
V2 External API - Files Endpoints
Provides file upload and download functionality.
Provides file upload, download, listing, metadata, and deletion functionality.
"""
import base64
@@ -16,19 +16,30 @@ from starlette import status
from backend.api.external.middleware import require_permission
from backend.data.auth.base import APIAuthorizationInfo
from backend.data.workspace import get_workspace, get_workspace_file
from backend.data.workspace import (
count_workspace_files,
get_workspace,
get_workspace_file,
list_workspace_files,
soft_delete_workspace_file,
)
from backend.util.cloud_storage import get_cloud_storage_handler
from backend.util.settings import Settings
from backend.util.virus_scanner import scan_content_safe
from backend.util.workspace_storage import get_workspace_storage
from .models import UploadFileResponse
from .common import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from .models import (
UploadWorkspaceFileResponse,
WorkspaceFileInfo,
WorkspaceFileListResponse,
)
from .rate_limit import file_upload_limiter
logger = logging.getLogger(__name__)
settings = Settings()
files_router = APIRouter()
file_workspace_router = APIRouter(tags=["files"])
# ============================================================================
@@ -36,6 +47,125 @@ files_router = APIRouter()
# ============================================================================
@file_workspace_router.get(
path="",
summary="List workspace files",
operation_id="listWorkspaceFiles",
)
async def list_files(
page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(
default=DEFAULT_PAGE_SIZE,
ge=1,
le=MAX_PAGE_SIZE,
description=f"Items per page (max {MAX_PAGE_SIZE})",
),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.DOWNLOAD_FILES)
),
) -> WorkspaceFileListResponse:
"""List files in the user's workspace."""
workspace = await get_workspace(auth.user_id)
if workspace is None:
return WorkspaceFileListResponse(
files=[], page=page, page_size=page_size, total_count=0, total_pages=0
)
total_count = await count_workspace_files(workspace.id)
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 0
offset = (page - 1) * page_size
files = await list_workspace_files(
workspace_id=workspace.id,
limit=page_size,
offset=offset,
)
return WorkspaceFileListResponse(
files=[
WorkspaceFileInfo(
id=f.id,
name=f.name,
path=f.path,
mime_type=f.mime_type,
size_bytes=f.size_bytes,
created_at=f.created_at,
updated_at=f.updated_at,
)
for f in files
],
page=page,
page_size=page_size,
total_count=total_count,
total_pages=total_pages,
)
@file_workspace_router.get(
path="/{file_id}",
summary="Get workspace file metadata",
operation_id="getWorkspaceFileInfo",
)
async def get_file(
file_id: str,
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.DOWNLOAD_FILES)
),
) -> WorkspaceFileInfo:
"""Get metadata for a specific file in the user's workspace."""
workspace = await get_workspace(auth.user_id)
if workspace is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Workspace not found",
)
file = await get_workspace_file(file_id, workspace.id)
if file is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"File #{file_id} not found",
)
return WorkspaceFileInfo(
id=file.id,
name=file.name,
path=file.path,
mime_type=file.mime_type,
size_bytes=file.size_bytes,
created_at=file.created_at,
updated_at=file.updated_at,
)
@file_workspace_router.delete(
path="/{file_id}",
summary="Delete file from workspace",
operation_id="deleteWorkspaceFile",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_file(
file_id: str,
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.UPLOAD_FILES)
),
) -> None:
"""Soft-delete a file from the user's workspace."""
workspace = await get_workspace(auth.user_id)
if workspace is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Workspace not found",
)
result = await soft_delete_workspace_file(file_id, workspace.id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"File #{file_id} not found",
)
def _create_file_size_error(size_bytes: int, max_size_mb: int) -> HTTPException:
"""Create standardized file size error response."""
return HTTPException(
@@ -47,9 +177,10 @@ def _create_file_size_error(size_bytes: int, max_size_mb: int) -> HTTPException:
)
@files_router.post(
@file_workspace_router.post(
path="/upload",
summary="Upload file",
summary="Upload file to workspace",
operation_id="uploadWorkspaceFile",
)
async def upload_file(
file: UploadFile = File(...),
@@ -59,7 +190,7 @@ async def upload_file(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.UPLOAD_FILES)
),
) -> UploadFileResponse:
) -> UploadWorkspaceFileResponse:
"""
Upload a file to cloud storage for use with agents.
@@ -98,7 +229,7 @@ async def upload_file(
base64_content = base64.b64encode(content).decode("utf-8")
data_uri = f"data:{content_type};base64,{base64_content}"
return UploadFileResponse(
return UploadWorkspaceFileResponse(
file_uri=data_uri,
file_name=file_name,
size=content_size,
@@ -114,7 +245,7 @@ async def upload_file(
user_id=auth.user_id,
)
return UploadFileResponse(
return UploadWorkspaceFileResponse(
file_uri=storage_path,
file_name=file_name,
size=content_size,
@@ -140,9 +271,10 @@ def _sanitize_filename_for_header(filename: str) -> str:
return f"attachment; filename*=UTF-8''{encoded}"
@files_router.get(
@file_workspace_router.get(
path="/{file_id}/download",
summary="Download file",
summary="Download file from workspace",
operation_id="getWorkspaceFileDownload",
)
async def download_file(
file_id: str,

View File

@@ -37,12 +37,13 @@ from .models import (
logger = logging.getLogger(__name__)
graphs_router = APIRouter()
graphs_router = APIRouter(tags=["graphs"])
@graphs_router.get(
path="",
summary="List graphs",
operation_id="listGraphs",
)
async def list_graphs(
page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
@@ -75,6 +76,7 @@ async def list_graphs(
@graphs_router.get(
path="/{graph_id}",
summary="Get graph details",
operation_id="getGraphDetails",
)
async def get_graph(
graph_id: str,
@@ -109,6 +111,7 @@ async def get_graph(
@graphs_router.post(
path="",
summary="Create graph",
operation_id="createGraph",
)
async def create_graph(
create_graph: GraphCreateRequest,
@@ -134,7 +137,8 @@ async def create_graph(
@graphs_router.put(
path="/{graph_id}",
summary="Update graph",
summary="Update graph by creating a new version",
operation_id="updateGraphCreateVersion",
)
async def update_graph(
graph_id: str,
@@ -229,6 +233,7 @@ async def update_graph(
@graphs_router.get(
path="/{graph_id}/versions",
summary="List graph versions",
operation_id="listGraphVersions",
)
async def list_graph_versions(
graph_id: str,
@@ -249,6 +254,7 @@ async def list_graph_versions(
@graphs_router.put(
path="/{graph_id}/versions/active",
summary="Set active graph version",
operation_id="updateGraphSetActiveVersion",
)
async def set_active_version(
graph_id: str,
@@ -299,6 +305,7 @@ async def set_active_version(
@graphs_router.patch(
path="/{graph_id}/settings",
summary="Update graph settings",
operation_id="updateGraphSettings",
)
async def update_graph_settings(
graph_id: str,
@@ -332,6 +339,7 @@ async def update_graph_settings(
@graphs_router.get(
path="/{graph_id}/library-agent",
summary="Get library agent for graph",
operation_id="getLibraryAgentForGraph",
)
async def get_library_agent_by_graph(
graph_id: str,
@@ -354,7 +362,8 @@ async def get_library_agent_by_graph(
@graphs_router.get(
path="/{graph_id}/blocks",
summary="List graph blocks",
summary="List blocks used in a graph",
operation_id="listBlocksInGraph",
)
async def list_graph_blocks(
graph_id: str,
@@ -395,6 +404,7 @@ async def list_graph_blocks(
@graphs_router.get(
path="/{graph_id}/credentials",
summary="Get graph credentials",
operation_id="getCredentialRequirementsForGraph",
)
async def list_graph_credential_requirements(
graph_id: str,

View File

@@ -8,6 +8,6 @@ from fastapi import APIRouter
from .credentials import credentials_router
integrations_router = APIRouter()
integrations_router = APIRouter(tags=["integrations"])
integrations_router.include_router(credentials_router)

View File

@@ -27,7 +27,8 @@ credentials_router = APIRouter()
@credentials_router.get(
path="/credentials",
summary="List credentials",
summary="List integration credentials",
operation_id="listIntegrationCredentials",
)
async def list_credentials(
provider: Optional[str] = Query(
@@ -51,7 +52,8 @@ async def list_credentials(
@credentials_router.post(
path="/credentials",
summary="Create API key credential",
summary="Add integration API key credential",
operation_id="addIntegrationAPIKeyCredential",
status_code=status.HTTP_201_CREATED,
)
async def create_credential(
@@ -61,7 +63,7 @@ async def create_credential(
),
) -> CredentialInfo:
"""
Create a new API key credential.
Create a new integration credential.
Only API key credentials can be created via the external API.
OAuth credentials must be set up through the web UI.
@@ -79,7 +81,8 @@ async def create_credential(
@credentials_router.delete(
path="/credentials/{credential_id}",
summary="Delete credential",
summary="Delete integration credential",
operation_id="deleteIntegrationCredential",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_credential(

View File

@@ -28,7 +28,7 @@ from ..rate_limit import execute_limiter
logger = logging.getLogger(__name__)
agents_router = APIRouter()
agents_router = APIRouter(tags=["library"])
# ============================================================================
@@ -39,6 +39,7 @@ agents_router = APIRouter()
@agents_router.get(
path="/agents",
summary="List library agents",
operation_id="listLibraryAgents",
)
async def list_library_agents(
published: Optional[bool] = Query(
@@ -81,6 +82,7 @@ async def list_library_agents(
@agents_router.get(
path="/agents/{agent_id}",
summary="Get library agent",
operation_id="getLibraryAgent",
)
async def get_library_agent(
agent_id: str,
@@ -99,6 +101,7 @@ async def get_library_agent(
@agents_router.patch(
path="/agents/{agent_id}",
summary="Update library agent",
operation_id="updateLibraryAgent",
)
async def update_library_agent(
request: LibraryAgentUpdateRequest,
@@ -123,6 +126,7 @@ async def update_library_agent(
@agents_router.delete(
path="/agents/{agent_id}",
summary="Delete library agent",
operation_id="deleteLibraryAgent",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_library_agent(
@@ -141,6 +145,7 @@ async def delete_library_agent(
@agents_router.post(
path="/agents/{agent_id}/fork",
summary="Fork library agent",
operation_id="forkLibraryAgent",
status_code=status.HTTP_201_CREATED,
)
async def fork_library_agent(
@@ -149,7 +154,12 @@ async def fork_library_agent(
require_permission(APIKeyPermission.WRITE_LIBRARY)
),
) -> LibraryAgent:
"""Fork (clone) a library agent."""
"""Fork (clone) a library agent.
Creates a deep copy of the agent's underlying graph and all its nodes,
assigning new IDs. The cloned graph is added to the user's library as
an independent agent that can be modified without affecting the original.
"""
forked = await library_db.fork_library_agent(
library_agent_id=agent_id,
user_id=auth.user_id,
@@ -160,6 +170,7 @@ async def fork_library_agent(
@agents_router.post(
path="/agents/{agent_id}/runs",
summary="Execute library agent",
operation_id="executeLibraryAgent",
)
async def execute_agent(
request: AgentRunRequest,
@@ -186,25 +197,20 @@ async def execute_agent(
user_id=auth.user_id,
)
try:
result = await execution_utils.add_graph_execution(
graph_id=library_agent.graph_id,
user_id=auth.user_id,
inputs=request.inputs,
graph_version=library_agent.graph_version,
graph_credentials_inputs=request.credentials_inputs,
)
return AgentGraphRun.from_internal(result)
except Exception as e:
logger.error(f"Failed to execute agent: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
result = await execution_utils.add_graph_execution(
graph_id=library_agent.graph_id,
user_id=auth.user_id,
inputs=request.inputs,
graph_version=library_agent.graph_version,
graph_credentials_inputs=request.credentials_inputs,
)
return AgentGraphRun.from_internal(result)
@agents_router.get(
path="/agents/{agent_id}/credentials",
summary="Get agent credentials",
summary="Get library agent credential requirements",
operation_id="getCredentialRequirementsForLibraryAgent",
)
async def list_agent_credential_requirements(
agent_id: str,

View File

@@ -3,7 +3,7 @@
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Security
from fastapi import APIRouter, Query, Security
from prisma.enums import APIKeyPermission
from starlette import status
@@ -23,12 +23,13 @@ from ..models import (
logger = logging.getLogger(__name__)
folders_router = APIRouter()
folders_router = APIRouter(tags=["library"])
@folders_router.get(
path="/folders",
summary="List folders",
summary="List folders in library",
operation_id="listLibraryFolders",
)
async def list_folders(
parent_id: Optional[str] = Query(
@@ -51,7 +52,8 @@ async def list_folders(
@folders_router.get(
path="/folders/tree",
summary="Get folder tree",
summary="Get library folder tree",
operation_id="getLibraryFolderTree",
)
async def get_folder_tree(
auth: APIAuthorizationInfo = Security(
@@ -68,7 +70,8 @@ async def get_folder_tree(
@folders_router.get(
path="/folders/{folder_id}",
summary="Get folder",
summary="Get folder in library",
operation_id="getLibraryFolder",
)
async def get_folder(
folder_id: str,
@@ -77,23 +80,17 @@ async def get_folder(
),
) -> LibraryFolder:
"""Get details of a specific folder."""
try:
folder = await library_db.get_folder(
folder_id=folder_id,
user_id=auth.user_id,
)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Folder #{folder_id} not found",
)
folder = await library_db.get_folder(
folder_id=folder_id,
user_id=auth.user_id,
)
return LibraryFolder.from_internal(folder)
@folders_router.post(
path="/folders",
summary="Create folder",
summary="Create folder in library",
operation_id="createLibraryFolder",
status_code=status.HTTP_201_CREATED,
)
async def create_folder(
@@ -103,23 +100,20 @@ async def create_folder(
),
) -> LibraryFolder:
"""Create a new folder in the user's library."""
try:
folder = await library_db.create_folder(
user_id=auth.user_id,
name=request.name,
parent_id=request.parent_id,
icon=request.icon,
color=request.color,
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
folder = await library_db.create_folder(
user_id=auth.user_id,
name=request.name,
parent_id=request.parent_id,
icon=request.icon,
color=request.color,
)
return LibraryFolder.from_internal(folder)
@folders_router.patch(
path="/folders/{folder_id}",
summary="Update folder",
summary="Update folder in library",
operation_id="updateLibraryFolder",
)
async def update_folder(
request: LibraryFolderUpdateRequest,
@@ -129,23 +123,20 @@ async def update_folder(
),
) -> LibraryFolder:
"""Update properties of a folder."""
try:
folder = await library_db.update_folder(
folder_id=folder_id,
user_id=auth.user_id,
name=request.name,
icon=request.icon,
color=request.color,
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
folder = await library_db.update_folder(
folder_id=folder_id,
user_id=auth.user_id,
name=request.name,
icon=request.icon,
color=request.color,
)
return LibraryFolder.from_internal(folder)
@folders_router.post(
path="/folders/{folder_id}/move",
summary="Move folder",
summary="Move folder in library",
operation_id="moveLibraryFolder",
)
async def move_folder(
request: LibraryFolderMoveRequest,
@@ -155,21 +146,18 @@ async def move_folder(
),
) -> LibraryFolder:
"""Move a folder to a new parent. Set target_parent_id to null to move to root."""
try:
folder = await library_db.move_folder(
folder_id=folder_id,
user_id=auth.user_id,
target_parent_id=request.target_parent_id,
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
folder = await library_db.move_folder(
folder_id=folder_id,
user_id=auth.user_id,
target_parent_id=request.target_parent_id,
)
return LibraryFolder.from_internal(folder)
@folders_router.delete(
path="/folders/{folder_id}",
summary="Delete folder",
summary="Delete folder in library",
operation_id="deleteLibraryFolder",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_folder(
@@ -178,14 +166,10 @@ async def delete_folder(
require_permission(APIKeyPermission.WRITE_LIBRARY)
),
) -> None:
"""Delete a folder. Agents in the folder will be moved to root. Subfolders are also deleted."""
try:
await library_db.delete_folder(
folder_id=folder_id,
user_id=auth.user_id,
)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Folder #{folder_id} not found",
)
"""
Delete a folder and its subfolders. Agents in this folder will be moved to root.
"""
await library_db.delete_folder(
folder_id=folder_id,
user_id=auth.user_id,
)

View File

@@ -35,12 +35,13 @@ from ..rate_limit import execute_limiter
logger = logging.getLogger(__name__)
presets_router = APIRouter()
presets_router = APIRouter(tags=["library", "presets"])
@presets_router.get(
path="/presets",
summary="List presets",
summary="List agent execution presets",
operation_id="listAgentRunPresets",
)
async def list_presets(
graph_id: Optional[str] = Query(default=None, description="Filter by graph ID"),
@@ -74,7 +75,8 @@ async def list_presets(
@presets_router.get(
path="/presets/{preset_id}",
summary="Get preset",
summary="Get agent execution preset",
operation_id="getAgentRunPreset",
)
async def get_preset(
preset_id: str,
@@ -98,7 +100,8 @@ async def get_preset(
@presets_router.post(
path="/presets",
summary="Create preset",
summary="Create agent execution preset",
operation_id="createAgentRunPreset",
status_code=status.HTTP_201_CREATED,
)
async def create_preset(
@@ -118,20 +121,17 @@ async def create_preset(
is_active=request.is_active,
)
try:
preset = await library_db.create_preset(
user_id=auth.user_id,
preset=creatable,
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
preset = await library_db.create_preset(
user_id=auth.user_id,
preset=creatable,
)
return AgentPreset.from_internal(preset)
@presets_router.post(
path="/presets/setup-trigger",
summary="Setup triggered preset",
operation_id="setupAgentRunTrigger",
status_code=status.HTTP_201_CREATED,
)
async def setup_trigger(
@@ -140,7 +140,14 @@ async def setup_trigger(
require_permission(APIKeyPermission.WRITE_LIBRARY)
),
) -> AgentPreset:
"""Create a preset with a webhook trigger for automatic execution."""
"""
Create a preset with a webhook trigger for automatic execution.
The agent's `trigger_setup_info` describes the required trigger configuration
schema and credentials. Use it to populate `trigger_config` and
`agent_credentials`.
"""
# Use internal trigger setup endpoint to avoid logic duplication:
from backend.api.features.library.routes.presets import (
setup_trigger as _internal_setup_trigger,
)
@@ -154,22 +161,17 @@ async def setup_trigger(
agent_credentials=request.agent_credentials,
)
try:
preset = await _internal_setup_trigger(
params=internal_request,
user_id=auth.user_id,
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
preset = await _internal_setup_trigger(
params=internal_request,
user_id=auth.user_id,
)
return AgentPreset.from_internal(preset)
@presets_router.patch(
path="/presets/{preset_id}",
summary="Update preset",
operation_id="updateAgentRunPreset",
summary="Update agent execution preset",
)
async def update_preset(
request: AgentPresetUpdateRequest,
@@ -179,25 +181,22 @@ async def update_preset(
),
) -> AgentPreset:
"""Update properties of a preset. Only provided fields will be updated."""
try:
preset = await library_db.update_preset(
user_id=auth.user_id,
preset_id=preset_id,
name=request.name,
description=request.description,
inputs=request.inputs,
credentials=request.credentials,
is_active=request.is_active,
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
preset = await library_db.update_preset(
user_id=auth.user_id,
preset_id=preset_id,
name=request.name,
description=request.description,
inputs=request.inputs,
credentials=request.credentials,
is_active=request.is_active,
)
return AgentPreset.from_internal(preset)
@presets_router.delete(
path="/presets/{preset_id}",
summary="Delete preset",
summary="Delete agent execution preset",
operation_id="deleteAgentRunPreset",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_preset(
@@ -215,7 +214,8 @@ async def delete_preset(
@presets_router.post(
path="/presets/{preset_id}/execute",
summary="Execute preset",
summary="Execute agent preset",
operation_id="executeAgentRunPreset",
)
async def execute_preset(
preset_id: str,
@@ -251,16 +251,12 @@ async def execute_preset(
merged_inputs = {**preset.inputs, **request.inputs}
merged_credentials = {**preset.credentials, **request.credentials_inputs}
try:
result = await execution_utils.add_graph_execution(
graph_id=preset.graph_id,
user_id=auth.user_id,
inputs=merged_inputs,
graph_version=preset.graph_version,
graph_credentials_inputs=merged_credentials,
preset_id=preset_id,
)
return AgentGraphRun.from_internal(result)
except Exception as e:
logger.error(f"Failed to execute preset: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
result = await execution_utils.add_graph_execution(
graph_id=preset.graph_id,
user_id=auth.user_id,
inputs=merged_inputs,
graph_version=preset.graph_version,
graph_credentials_inputs=merged_credentials,
preset_id=preset_id,
)
return AgentGraphRun.from_internal(result)

View File

@@ -10,7 +10,6 @@ from typing import Literal, Optional
from fastapi import APIRouter, File, HTTPException, Path, Query, Security, UploadFile
from prisma.enums import APIKeyPermission
from prisma.enums import ContentType as SearchContentType
from starlette import status
from backend.api.external.middleware import require_auth, require_permission
@@ -21,7 +20,6 @@ from backend.api.features.store.db import (
StoreAgentsSortOptions,
StoreCreatorsSortOptions,
)
from backend.api.features.store.hybrid_search import unified_hybrid_search
from backend.data.auth.base import APIAuthorizationInfo
from backend.util.virus_scanner import scan_content_safe
@@ -38,16 +36,14 @@ from .models import (
MarketplaceCreatorDetails,
MarketplaceCreatorsResponse,
MarketplaceMediaUploadResponse,
MarketplaceSearchResponse,
MarketplaceSearchResult,
MarketplaceUserProfile,
MarketplaceUserProfileUpdateRequest,
)
from .rate_limit import media_upload_limiter, search_limiter
from .rate_limit import media_upload_limiter
logger = logging.getLogger(__name__)
marketplace_router = APIRouter()
marketplace_router = APIRouter(tags=["marketplace"])
# ============================================================================
@@ -57,7 +53,8 @@ marketplace_router = APIRouter()
@marketplace_router.get(
path="/agents",
summary="List marketplace agents",
summary="List or search marketplace agents",
operation_id="listMarketplaceAgents",
)
async def list_agents(
featured: bool = Query(
@@ -100,7 +97,8 @@ async def list_agents(
@marketplace_router.get(
path="/agents/by-version/{version_id}",
summary="Get agent by version ID",
summary="Get marketplace agent by version ID",
operation_id="getMarketplaceAgentByListingVersion",
)
async def get_agent_by_version(
version_id: str,
@@ -113,7 +111,8 @@ async def get_agent_by_version(
@marketplace_router.get(
path="/agents/{username}/{agent_name}",
summary="Get agent details",
summary="Get marketplace agent details",
operation_id="getMarketplaceAgent",
)
async def get_agent_details(
username: str,
@@ -133,7 +132,8 @@ async def get_agent_details(
@marketplace_router.post(
path="/agents/{username}/{agent_name}/add-to-library",
summary="Add agent to library",
summary="Add marketplace agent to library",
operation_id="addMarketplaceAgentToLibrary",
status_code=status.HTTP_201_CREATED,
)
async def add_agent_to_library(
@@ -161,63 +161,6 @@ async def add_agent_to_library(
return LibraryAgent.from_internal(agent)
# ============================================================================
# Search
# ============================================================================
@marketplace_router.get(
path="/search",
summary="Search marketplace",
)
async def search_marketplace(
query: str = Query(description="Search query"),
content_types: Optional[list[SearchContentType]] = Query(
default=None, description="Content types to filter by"
),
category: Optional[str] = Query(default=None, description="Filter by category"),
page: int = Query(ge=1, default=1),
page_size: int = Query(ge=1, le=MAX_PAGE_SIZE, default=DEFAULT_PAGE_SIZE),
auth: APIAuthorizationInfo = Security(require_auth),
) -> MarketplaceSearchResponse:
"""
Search the marketplace using hybrid search (literal + semantic).
Searches across agents, blocks, and documentation. Results are ranked
by a combination of keyword matching and semantic similarity.
"""
search_limiter.check(auth.user_id)
results, total_count = await unified_hybrid_search(
query=query,
content_types=content_types,
category=category,
page=page,
page_size=page_size,
user_id=auth.user_id,
)
total_pages = max(1, (total_count + page_size - 1) // page_size)
return MarketplaceSearchResponse(
results=[
MarketplaceSearchResult(
content_type=r.get("content_type", ""),
content_id=r.get("content_id", ""),
searchable_text=r.get("searchable_text", ""),
metadata=r.get("metadata"),
updated_at=r.get("updated_at"),
combined_score=r.get("combined_score"),
)
for r in results
],
page=page,
page_size=page_size,
total_count=total_count,
total_pages=total_pages,
)
# ============================================================================
# Creators
# ============================================================================
@@ -226,6 +169,7 @@ async def search_marketplace(
@marketplace_router.get(
path="/creators",
summary="List marketplace creators",
operation_id="listMarketplaceCreators",
)
async def list_creators(
featured: bool = Query(
@@ -261,13 +205,14 @@ async def list_creators(
@marketplace_router.get(
path="/creators/{username}",
summary="Get creator details",
summary="Get marketplace creator details",
operation_id="getMarketplaceCreator",
)
async def get_creator_details(
username: str,
auth: APIAuthorizationInfo = Security(require_auth),
) -> MarketplaceCreatorDetails:
"""Get details on a marketplace creator."""
"""Get a marketplace creator's profile w/ stats."""
username = urllib.parse.unquote(username).lower()
creator = await store_cache._get_cached_creator_details(username=username)
return MarketplaceCreatorDetails.from_internal(creator)
@@ -280,14 +225,15 @@ async def get_creator_details(
@marketplace_router.get(
path="/profile",
summary="Get my profile",
summary="Get my marketplace profile",
operation_id="getMarketplaceMyProfile",
)
async def get_profile(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.READ_STORE)
),
) -> MarketplaceCreatorDetails:
"""Get the authenticated user's marketplace profile."""
"""Get the authenticated user's marketplace profile w/ creator stats."""
profile = await store_db.get_user_profile(auth.user_id)
if not profile:
raise HTTPException(
@@ -301,7 +247,8 @@ async def get_profile(
@marketplace_router.patch(
path="/profile",
summary="Update my profile",
summary="Update my marketplace profile",
operation_id="updateMarketplaceMyProfile",
)
async def update_profile(
request: MarketplaceUserProfileUpdateRequest,
@@ -331,7 +278,8 @@ async def update_profile(
@marketplace_router.get(
path="/submissions",
summary="List my submissions",
summary="List my marketplace submissions",
operation_id="listMarketplaceSubmissions",
)
async def list_submissions(
page: int = Query(ge=1, default=1),
@@ -360,7 +308,8 @@ async def list_submissions(
@marketplace_router.post(
path="/submissions",
summary="Create submission",
summary="Create marketplace submission",
operation_id="createMarketplaceSubmission",
)
async def create_submission(
request: MarketplaceAgentSubmissionCreateRequest,
@@ -391,7 +340,8 @@ async def create_submission(
@marketplace_router.put(
path="/submissions/{version_id}",
summary="Edit submission",
summary="Edit marketplace submission",
operation_id="updateMarketplaceSubmission",
)
async def edit_submission(
request: MarketplaceAgentSubmissionEditRequest,
@@ -423,25 +373,26 @@ async def edit_submission(
@marketplace_router.delete(
path="/submissions/{submission_id}",
summary="Delete submission",
path="/submissions/{version_id}",
summary="Delete marketplace submission",
operation_id="deleteMarketplaceSubmission",
)
async def delete_submission(
submission_id: str,
version_id: str,
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.WRITE_STORE)
),
) -> None:
"""Delete a marketplace listing submission."""
"""Delete a marketplace listing submission. Approved listings can not be deleted."""
success = await store_db.delete_store_submission(
user_id=auth.user_id,
submission_id=submission_id,
store_listing_version_id=version_id,
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Submission #{submission_id} not found",
detail=f"Submission #{version_id} not found",
)
@@ -452,7 +403,8 @@ async def delete_submission(
@marketplace_router.post(
path="/submissions/media",
summary="Upload submission media",
summary="Upload marketplace submission media",
operation_id="uploadMarketplaceSubmissionMedia",
)
async def upload_submission_media(
file: UploadFile = File(...),

View File

@@ -386,6 +386,26 @@ class AgentRunScheduleListResponse(PaginatedResponse):
# ============================================================================
class TriggerSetupInfo(BaseModel):
"""
Trigger configuration requirements for agents that support webhook triggers.
Use `config_schema` and `credentials_input_name` to populate the
`trigger_config` and `agent_credentials` fields when calling
``POST /library/presets/setup-trigger``.
"""
provider: str = Field(description="Trigger provider (e.g. 'github')")
config_schema: dict[str, Any] = Field(
description="JSON Schema for the trigger block's input"
)
credentials_input_name: Optional[str] = Field(
description=(
"Name of the credentials input field, if the trigger requires credentials"
)
)
class LibraryAgent(BaseModel):
"""An agent in the user's library."""
@@ -401,11 +421,24 @@ class LibraryAgent(BaseModel):
creator_name: str
input_schema: dict[str, Any] = Field(description="Input schema for the agent")
output_schema: dict[str, Any] = Field(description="Output schema for the agent")
trigger_setup_info: Optional[TriggerSetupInfo] = Field(
default=None,
description="Trigger configuration requirements; "
"present if the agent has a webhook trigger input",
)
created_at: datetime
updated_at: datetime
@classmethod
def from_internal(cls, agent: _LibraryAgent) -> Self:
trigger_info = None
if agent.trigger_setup_info:
trigger_info = TriggerSetupInfo(
provider=agent.trigger_setup_info.provider,
config_schema=agent.trigger_setup_info.config_schema,
credentials_input_name=agent.trigger_setup_info.credentials_input_name,
)
return cls(
id=agent.id,
graph_id=agent.graph_id,
@@ -419,6 +452,7 @@ class LibraryAgent(BaseModel):
creator_name=agent.creator_name,
input_schema=agent.input_schema,
output_schema=agent.output_schema,
trigger_setup_info=trigger_info,
created_at=agent.created_at,
updated_at=agent.updated_at,
)
@@ -909,12 +943,12 @@ class CredentialRequirementsResponse(BaseModel):
# ============================================================================
# File Models
# File Workspace Models
# ============================================================================
class UploadFileResponse(BaseModel):
"""Response after uploading a file."""
class UploadWorkspaceFileResponse(BaseModel):
"""Response after uploading a file to the user's workspace."""
file_uri: str = Field(description="URI to reference the uploaded file in agents")
file_name: str
@@ -923,6 +957,24 @@ class UploadFileResponse(BaseModel):
expires_in_hours: int
class WorkspaceFileInfo(BaseModel):
"""Metadata for a file in the user's workspace."""
id: str
name: str
path: str
mime_type: str
size_bytes: int = Field(description="File size in bytes")
created_at: datetime
updated_at: datetime
class WorkspaceFileListResponse(PaginatedResponse):
"""Response for listing workspace files."""
files: list[WorkspaceFileInfo]
# ============================================================================
# Marketplace Models
# ============================================================================

View File

@@ -8,28 +8,26 @@ from fastapi import APIRouter
from .blocks import blocks_router
from .credits import credits_router
from .files import files_router
from .files import file_workspace_router
from .graphs import graphs_router
from .integrations import integrations_router
from .library import library_router
from .marketplace import marketplace_router
from .runs import runs_router
from .schedules import graph_schedules_router, schedules_router
from .search import search_router
v2_router = APIRouter()
# Include all sub-routers
v2_router.include_router(graphs_router, prefix="/graphs", tags=["graphs"])
v2_router.include_router(graph_schedules_router, prefix="/graphs", tags=["schedules"])
v2_router.include_router(schedules_router, prefix="/schedules", tags=["schedules"])
v2_router.include_router(blocks_router, prefix="/blocks", tags=["blocks"])
v2_router.include_router(
marketplace_router, prefix="/marketplace", tags=["marketplace"]
)
v2_router.include_router(library_router, prefix="/library", tags=["library"])
v2_router.include_router(runs_router, prefix="/runs", tags=["runs"])
v2_router.include_router(credits_router, prefix="/credits", tags=["credits"])
v2_router.include_router(
integrations_router, prefix="/integrations", tags=["integrations"]
)
v2_router.include_router(files_router, prefix="/files", tags=["files"])
v2_router.include_router(blocks_router, prefix="/blocks")
v2_router.include_router(credits_router, prefix="/credits")
v2_router.include_router(file_workspace_router, prefix="/files")
v2_router.include_router(graph_schedules_router, prefix="/graphs")
v2_router.include_router(graphs_router, prefix="/graphs")
v2_router.include_router(integrations_router, prefix="/integrations")
v2_router.include_router(library_router, prefix="/library")
v2_router.include_router(marketplace_router, prefix="/marketplace")
v2_router.include_router(runs_router, prefix="/runs")
v2_router.include_router(schedules_router, prefix="/schedules")
v2_router.include_router(search_router, prefix="/search")

View File

@@ -9,7 +9,7 @@ import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Security
from fastapi import APIRouter, HTTPException, Path, Query, Security
from prisma.enums import APIKeyPermission, ReviewStatus
from pydantic import JsonValue
from starlette import status
@@ -36,7 +36,7 @@ from .models import (
logger = logging.getLogger(__name__)
settings = Settings()
runs_router = APIRouter()
runs_router = APIRouter(tags=["runs"])
# ============================================================================
@@ -46,7 +46,8 @@ runs_router = APIRouter()
@runs_router.get(
path="",
summary="List runs",
summary="List agent runs",
operation_id="listAgentRuns",
)
async def list_runs(
graph_id: Optional[str] = Query(default=None, description="Filter by graph ID"),
@@ -80,10 +81,11 @@ async def list_runs(
@runs_router.get(
path="/{run_id}",
summary="Get run details",
summary="Get agent run details",
operation_id="getAgentRunDetails",
)
async def get_run(
run_id: str,
run_id: str = Path(description="Graph Execution ID"),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.READ_RUN)
),
@@ -106,10 +108,11 @@ async def get_run(
@runs_router.post(
path="/{run_id}/stop",
summary="Stop run",
summary="Stop agent run",
operation_id="stopAgentRun",
)
async def stop_run(
run_id: str,
run_id: str = Path(description="Graph Execution ID"),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.WRITE_RUN)
),
@@ -153,10 +156,11 @@ async def stop_run(
@runs_router.delete(
path="/{run_id}",
summary="Delete run",
summary="Delete agent run",
operation_id="deleteAgentRun",
)
async def delete_run(
run_id: str,
run_id: str = Path(description="Graph Execution ID"),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.WRITE_RUN)
),
@@ -175,10 +179,11 @@ async def delete_run(
@runs_router.post(
path="/{run_id}/share",
summary="Enable run sharing",
summary="Enable sharing for an agent run",
operation_id="enableAgentRunShare",
)
async def enable_sharing(
run_id: str,
run_id: str = Path(description="Graph Execution ID"),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.READ_RUN, APIKeyPermission.SHARE_RUN)
),
@@ -212,11 +217,12 @@ async def enable_sharing(
@runs_router.delete(
path="/{run_id}/share",
summary="Disable run sharing",
summary="Disable sharing for an agent run",
operation_id="disableAgentRunShare",
status_code=status.HTTP_204_NO_CONTENT,
)
async def disable_sharing(
run_id: str,
run_id: str = Path(description="Graph Execution ID"),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.SHARE_RUN)
),
@@ -248,10 +254,13 @@ async def disable_sharing(
@runs_router.get(
path="/reviews",
summary="List run reviews",
summary="List agent run human-in-the-loop reviews",
operation_id="listAgentRunReviews",
)
async def list_reviews(
run_id: Optional[str] = Query(default=None, description="Filter by run ID"),
run_id: Optional[str] = Query(
default=None, description="Filter by graph execution ID"
),
status: Optional[ReviewStatus] = Query(
description="Filter by review status",
),
@@ -269,7 +278,7 @@ async def list_reviews(
"""
List human-in-the-loop reviews for agent runs.
Defaults to reviews with status WAITING if no status filter is given.
Returns reviews with status WAITING if no status filter is given.
"""
reviews, pagination = await review_db.get_reviews(
user_id=auth.user_id,
@@ -290,11 +299,12 @@ async def list_reviews(
@runs_router.post(
path="/{run_id}/reviews",
summary="Submit run reviews",
summary="Submit agent run human-in-the-loop reviews",
operation_id="submitAgentRunReviews",
)
async def submit_reviews(
request: AgentRunReviewsSubmitRequest,
run_id: str,
run_id: str = Path(description="Graph Execution ID"),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.WRITE_RUN_REVIEW)
),

View File

@@ -27,7 +27,7 @@ from .models import (
logger = logging.getLogger(__name__)
schedules_router = APIRouter()
schedules_router = APIRouter(tags=["graphs", "schedules"])
# ============================================================================
@@ -37,7 +37,8 @@ schedules_router = APIRouter()
@schedules_router.get(
path="",
summary="List schedules",
summary="List run schedules",
operation_id="listGraphRunSchedules",
)
async def list_all_schedules(
graph_id: Optional[str] = Query(default=None, description="Filter by graph ID"),
@@ -77,7 +78,8 @@ async def list_all_schedules(
@schedules_router.delete(
path="/{schedule_id}",
summary="Delete schedule",
summary="Delete run schedule",
operation_id="deleteGraphRunSchedule",
)
async def delete_schedule(
schedule_id: str,
@@ -105,12 +107,13 @@ async def delete_schedule(
# These are included in the graphs router via include_router
# ============================================================================
graph_schedules_router = APIRouter()
graph_schedules_router = APIRouter(tags=["graphs"])
@graph_schedules_router.post(
path="/{graph_id}/schedules",
summary="Create graph schedule",
summary="Create run schedule",
operation_id="createGraphRunSchedule",
)
async def create_graph_schedule(
request: AgentRunScheduleCreateRequest,

View File

@@ -0,0 +1,76 @@
"""
V2 External API - Search Endpoints
Cross-domain hybrid search across agents, blocks, and documentation.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Query, Security
from prisma.enums import ContentType as SearchContentType
from backend.api.external.middleware import require_auth
from backend.api.features.store.hybrid_search import unified_hybrid_search
from backend.data.auth.base import APIAuthorizationInfo
from .common import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from .models import MarketplaceSearchResponse, MarketplaceSearchResult
from .rate_limit import search_limiter
logger = logging.getLogger(__name__)
search_router = APIRouter(tags=["search"])
@search_router.get(
path="",
summary="Search content and capabilities of the platform",
operation_id="search",
)
async def search(
query: str = Query(description="Search query"),
content_types: Optional[list[SearchContentType]] = Query(
default=None, description="Content types to filter by"
),
category: Optional[str] = Query(default=None, description="Filter by category"),
page: int = Query(ge=1, default=1),
page_size: int = Query(ge=1, le=MAX_PAGE_SIZE, default=DEFAULT_PAGE_SIZE),
auth: APIAuthorizationInfo = Security(require_auth),
) -> MarketplaceSearchResponse:
"""
Search the platform's content and capabilities (hybrid search: literal + semantic).
Searches across agents, blocks, and documentation. Results are ranked
by a combination of keyword matching and semantic similarity.
"""
search_limiter.check(auth.user_id)
results, total_count = await unified_hybrid_search(
query=query,
content_types=content_types,
category=category,
page=page,
page_size=page_size,
user_id=auth.user_id,
)
total_pages = max(1, (total_count + page_size - 1) // page_size)
return MarketplaceSearchResponse(
results=[
MarketplaceSearchResult(
content_type=r.get("content_type", ""),
content_id=r.get("content_id", ""),
searchable_text=r.get("searchable_text", ""),
metadata=r.get("metadata"),
updated_at=r.get("updated_at"),
combined_score=r.get("combined_score"),
)
for r in results
],
page=page,
page_size=page_size,
total_count=total_count,
total_pages=total_pages,
)

View File

@@ -518,22 +518,22 @@ async def get_store_submissions(
async def delete_store_submission(
user_id: str,
submission_id: str,
store_listing_version_id: str,
) -> bool:
"""
Delete a store submission version as the submitting user.
Args:
user_id: ID of the authenticated user
submission_id: StoreListingVersion ID to delete
store_listing_version_id: StoreListingVersion ID to delete
Returns:
bool: True if successfully deleted
"""
try:
# Find the submission version with ownership check
version = await prisma.models.StoreListingVersion.prisma().find_first(
where={"id": submission_id}, include={"StoreListing": True}
version = await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id}, include={"StoreListing": True}
)
if (
@@ -546,7 +546,7 @@ async def delete_store_submission(
# Prevent deletion of approved submissions
if version.submissionStatus == prisma.enums.SubmissionStatus.APPROVED:
raise store_exceptions.InvalidOperationError(
"Cannot delete approved submissions"
"Cannot delete approved store listings"
)
# Delete the version

View File

@@ -354,7 +354,7 @@ async def delete_submission(
"""Delete a marketplace listing submission"""
result = await store_db.delete_store_submission(
user_id=user_id,
submission_id=submission_id,
store_listing_version_id=submission_id,
)
return result