feat(platform): add external api routes for store search and tool usage (#11463)

We want to allow external tools to explore the marketplace and use the
chat agent tools


### Changes 🏗️

- add store api routes
- add tool api routes

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [x] tested all endpoints work

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Swifty
2025-12-01 13:04:03 +01:00
committed by GitHub
parent 6db18b8445
commit 00148f4e3d
9 changed files with 410 additions and 5 deletions

View File

@@ -3,6 +3,7 @@ from fastapi import FastAPI
from backend.monitoring.instrumentation import instrument_fastapi
from backend.server.middleware.security import SecurityHeadersMiddleware
from .routes.tools import tools_router
from .routes.v1 import v1_router
external_app = FastAPI(
@@ -14,6 +15,7 @@ external_app = FastAPI(
external_app.add_middleware(SecurityHeadersMiddleware)
external_app.include_router(v1_router, prefix="/v1")
external_app.include_router(tools_router, prefix="/v1")
# Add Prometheus instrumentation
instrument_fastapi(

View File

@@ -0,0 +1,239 @@
"""External API routes for chat tools - stateless HTTP endpoints.
Note: These endpoints use ephemeral sessions that are not persisted to Redis.
As a result, session-based rate limiting (max_agent_runs, max_agent_schedules)
is not enforced for external API calls. Each request creates a fresh session
with zeroed counters. Rate limiting for external API consumers should be
handled separately (e.g., via API key quotas).
"""
import logging
from typing import Any
from fastapi import APIRouter, Security
from prisma.enums import APIKeyPermission
from pydantic import BaseModel, Field
from backend.data.api_key import APIKeyInfo
from backend.server.external.middleware import require_permission
from backend.server.v2.chat.model import ChatSession
from backend.server.v2.chat.tools import (
find_agent_tool,
get_agent_details_tool,
get_required_setup_info_tool,
run_agent_tool,
setup_agent_tool,
)
from backend.server.v2.chat.tools.models import ToolResponseBase
logger = logging.getLogger(__name__)
tools_router = APIRouter(prefix="/tools", tags=["tools"])
# Note: We use Security() as a function parameter dependency (api_key: APIKeyInfo = Security(...))
# rather than in the decorator's dependencies= list. This avoids duplicate permission checks
# while still enforcing auth AND giving us access to the api_key for extracting user_id.
# Request models
class FindAgentRequest(BaseModel):
query: str = Field(..., description="Search query for finding agents")
class AgentSlugRequest(BaseModel):
username_agent_slug: str = Field(
...,
description="The marketplace agent slug (e.g., 'username/agent-name')",
)
class GetRequiredSetupInfoRequest(AgentSlugRequest):
inputs: dict[str, Any] = Field(
default_factory=dict,
description="The input dictionary you plan to provide",
)
class RunAgentRequest(AgentSlugRequest):
inputs: dict[str, Any] = Field(
default_factory=dict,
description="Dictionary of input values for the agent",
)
class SetupAgentRequest(AgentSlugRequest):
setup_type: str = Field(
default="schedule",
description="Type of setup: 'schedule' for cron, 'webhook' for triggers",
)
name: str = Field(..., description="Name for this setup/schedule")
description: str | None = Field(None, description="Description of this setup")
cron: str | None = Field(
None,
description="Cron expression (5 fields: minute hour day month weekday)",
)
timezone: str = Field(
default="UTC",
description="IANA timezone (e.g., 'America/New_York', 'UTC')",
)
inputs: dict[str, Any] = Field(
default_factory=dict,
description="Dictionary with required inputs for the agent",
)
webhook_config: dict[str, Any] | None = Field(
None,
description="Webhook configuration (required if setup_type is 'webhook')",
)
def _create_ephemeral_session(user_id: str | None) -> ChatSession:
"""Create an ephemeral session for stateless API requests.
Note: These sessions are NOT persisted to Redis, so session-based rate
limiting (max_agent_runs, max_agent_schedules) will not be enforced
across requests.
"""
return ChatSession.new(user_id)
@tools_router.post(path="/find-agent")
async def find_agent(
request: FindAgentRequest,
api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)),
) -> dict[str, Any]:
"""
Search for agents in the marketplace based on capabilities and user needs.
Args:
request: Search query for finding agents
Returns:
List of matching agents or no results response
"""
session = _create_ephemeral_session(api_key.user_id)
result = await find_agent_tool._execute(
user_id=api_key.user_id,
session=session,
query=request.query,
)
return _response_to_dict(result)
@tools_router.post(path="/get-agent-details")
async def get_agent_details(
request: AgentSlugRequest,
api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)),
) -> dict[str, Any]:
"""
Get detailed information about a specific agent including inputs,
credentials required, and execution options.
Args:
request: Agent slug in format 'username/agent-name'
Returns:
Detailed agent information
"""
session = _create_ephemeral_session(api_key.user_id)
result = await get_agent_details_tool._execute(
user_id=api_key.user_id,
session=session,
username_agent_slug=request.username_agent_slug,
)
return _response_to_dict(result)
@tools_router.post(path="/get-required-setup-info")
async def get_required_setup_info(
request: GetRequiredSetupInfoRequest,
api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)),
) -> dict[str, Any]:
"""
Check if an agent can be set up with the provided input data and credentials.
Validates that you have all required inputs before running or scheduling.
Args:
request: Agent slug and optional inputs to validate
Returns:
Setup requirements and user readiness status
"""
session = _create_ephemeral_session(api_key.user_id)
result = await get_required_setup_info_tool._execute(
user_id=api_key.user_id,
session=session,
username_agent_slug=request.username_agent_slug,
inputs=request.inputs,
)
return _response_to_dict(result)
@tools_router.post(path="/run-agent")
async def run_agent(
request: RunAgentRequest,
api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)),
) -> dict[str, Any]:
"""
Run an agent immediately (one-off manual execution).
IMPORTANT: Before calling this endpoint, first call get-agent-details
to determine what inputs are required.
Args:
request: Agent slug and input values
Returns:
Execution started response with execution_id
"""
session = _create_ephemeral_session(api_key.user_id)
result = await run_agent_tool._execute(
user_id=api_key.user_id,
session=session,
username_agent_slug=request.username_agent_slug,
inputs=request.inputs,
)
return _response_to_dict(result)
@tools_router.post(path="/setup-agent")
async def setup_agent(
request: SetupAgentRequest,
api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)),
) -> dict[str, Any]:
"""
Set up an agent with credentials and configure it for scheduled execution
or webhook triggers.
For SCHEDULED execution:
- Cron format: "minute hour day month weekday"
- Examples: "0 9 * * 1-5" (9am weekdays), "0 0 * * *" (daily at midnight)
- Timezone: Use IANA timezone names like "America/New_York", "Europe/London"
For WEBHOOK triggers:
- The agent will be triggered by external events
Args:
request: Agent slug, setup type, schedule configuration, and inputs
Returns:
Schedule or webhook created response
"""
session = _create_ephemeral_session(api_key.user_id)
result = await setup_agent_tool._execute(
user_id=api_key.user_id,
session=session,
username_agent_slug=request.username_agent_slug,
setup_type=request.setup_type,
name=request.name,
description=request.description,
cron=request.cron,
timezone=request.timezone,
inputs=request.inputs,
webhook_config=request.webhook_config,
)
return _response_to_dict(result)
def _response_to_dict(result: ToolResponseBase) -> dict[str, Any]:
"""Convert a tool response to a dictionary for JSON serialization."""
return result.model_dump()

View File

@@ -1,12 +1,15 @@
import logging
import urllib.parse
from collections import defaultdict
from typing import Annotated, Any, Optional, Sequence
from typing import Annotated, Any, Literal, Optional, Sequence
from fastapi import APIRouter, Body, HTTPException, Security
from prisma.enums import AgentExecutionStatus, APIKeyPermission
from typing_extensions import TypedDict
import backend.data.block
import backend.server.v2.store.cache as store_cache
import backend.server.v2.store.model as store_model
from backend.data import execution as execution_db
from backend.data import graph as graph_db
from backend.data.api_key import APIKeyInfo
@@ -144,3 +147,149 @@ async def get_graph_execution_results(
else None
),
)
##############################################
############### Store Endpoints ##############
##############################################
@v1_router.get(
path="/store/agents",
tags=["store"],
dependencies=[Security(require_permission(APIKeyPermission.READ_STORE))],
response_model=store_model.StoreAgentsResponse,
)
async def get_store_agents(
featured: bool = False,
creator: str | None = None,
sorted_by: Literal["rating", "runs", "name", "updated_at"] | None = None,
search_query: str | None = None,
category: str | None = None,
page: int = 1,
page_size: int = 20,
) -> store_model.StoreAgentsResponse:
"""
Get a paginated list of agents from the store with optional filtering and sorting.
Args:
featured: Filter to only show featured agents
creator: Filter agents by creator username
sorted_by: Sort agents by "runs", "rating", "name", or "updated_at"
search_query: Search agents by name, subheading and description
category: Filter agents by category
page: Page number for pagination (default 1)
page_size: Number of agents per page (default 20)
Returns:
StoreAgentsResponse: Paginated list of agents matching the filters
"""
if page < 1:
raise HTTPException(status_code=422, detail="Page must be greater than 0")
if page_size < 1:
raise HTTPException(status_code=422, detail="Page size must be greater than 0")
agents = await store_cache._get_cached_store_agents(
featured=featured,
creator=creator,
sorted_by=sorted_by,
search_query=search_query,
category=category,
page=page,
page_size=page_size,
)
return agents
@v1_router.get(
path="/store/agents/{username}/{agent_name}",
tags=["store"],
dependencies=[Security(require_permission(APIKeyPermission.READ_STORE))],
response_model=store_model.StoreAgentDetails,
)
async def get_store_agent(
username: str,
agent_name: str,
) -> store_model.StoreAgentDetails:
"""
Get details of a specific store agent by username and agent name.
Args:
username: Creator's username
agent_name: Name/slug of the agent
Returns:
StoreAgentDetails: Detailed information about the agent
"""
username = urllib.parse.unquote(username).lower()
agent_name = urllib.parse.unquote(agent_name).lower()
agent = await store_cache._get_cached_agent_details(
username=username, agent_name=agent_name
)
return agent
@v1_router.get(
path="/store/creators",
tags=["store"],
dependencies=[Security(require_permission(APIKeyPermission.READ_STORE))],
response_model=store_model.CreatorsResponse,
)
async def get_store_creators(
featured: bool = False,
search_query: str | None = None,
sorted_by: Literal["agent_rating", "agent_runs", "num_agents"] | None = None,
page: int = 1,
page_size: int = 20,
) -> store_model.CreatorsResponse:
"""
Get a paginated list of store creators with optional filtering and sorting.
Args:
featured: Filter to only show featured creators
search_query: Search creators by profile description
sorted_by: Sort by "agent_rating", "agent_runs", or "num_agents"
page: Page number for pagination (default 1)
page_size: Number of creators per page (default 20)
Returns:
CreatorsResponse: Paginated list of creators matching the filters
"""
if page < 1:
raise HTTPException(status_code=422, detail="Page must be greater than 0")
if page_size < 1:
raise HTTPException(status_code=422, detail="Page size must be greater than 0")
creators = await store_cache._get_cached_store_creators(
featured=featured,
search_query=search_query,
sorted_by=sorted_by,
page=page,
page_size=page_size,
)
return creators
@v1_router.get(
path="/store/creators/{username}",
tags=["store"],
dependencies=[Security(require_permission(APIKeyPermission.READ_STORE))],
response_model=store_model.CreatorDetails,
)
async def get_store_creator(
username: str,
) -> store_model.CreatorDetails:
"""
Get details of a specific store creator by username.
Args:
username: Creator's username
Returns:
CreatorDetails: Detailed information about the creator
"""
username = urllib.parse.unquote(username).lower()
creator = await store_cache._get_cached_creator_details(username=username)
return creator

View File

@@ -64,7 +64,10 @@ async def create_session(
CreateSessionResponse: Details of the created session.
"""
logger.info(f"Creating session with user_id: {user_id}")
logger.info(
f"Creating session with user_id: "
f"...{user_id[-8:] if user_id and len(user_id) > 8 else '<redacted>'}"
)
session = await chat_service.create_chat_session(user_id)

View File

@@ -396,8 +396,7 @@ async def create_library_agent(
DatabaseError: If there's an error during creation or if image generation fails.
"""
logger.info(
f"Creating library agent for graph #{graph.id} v{graph.version}; "
f"user #{user_id}"
f"Creating library agent for graph #{graph.id} v{graph.version}; user:<redacted>"
)
graph_entries = (
[graph, *graph.sub_graphs] if create_library_agents_for_sub_graphs else [graph]

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "APIKeyPermission" ADD VALUE 'READ_STORE';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "APIKeyPermission" ADD VALUE 'USE_TOOLS';

View File

@@ -910,6 +910,8 @@ enum APIKeyPermission {
READ_GRAPH // Can get graph versions and details
EXECUTE_BLOCK // Can execute individual blocks
READ_BLOCK // Can get block information
READ_STORE // Can read store agents and creators
USE_TOOLS // Can use chat tools via external API
}
model APIKey {

View File

@@ -5291,7 +5291,14 @@
},
"APIKeyPermission": {
"type": "string",
"enum": ["EXECUTE_GRAPH", "READ_GRAPH", "EXECUTE_BLOCK", "READ_BLOCK"],
"enum": [
"EXECUTE_GRAPH",
"READ_GRAPH",
"EXECUTE_BLOCK",
"READ_BLOCK",
"READ_STORE",
"USE_TOOLS"
],
"title": "APIKeyPermission"
},
"APIKeyStatus": {