This commit is contained in:
Reinier van der Leer
2026-03-08 09:39:31 +01:00
parent a8a729b9e1
commit b896ef0b39
9 changed files with 310 additions and 352 deletions

View File

@@ -220,6 +220,7 @@ async def delete_graph(
):
await on_graph_deactivate(active_version, user_id=auth.user_id)
# FIXME: maybe only expose delete for library agents?
version_count = await graph_db.delete_graph(graph_id, user_id=auth.user_id)
return GraphDeleteResponse(version_count=version_count)

View File

@@ -11,18 +11,13 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Path, Query, Security
from prisma.enums import APIKeyPermission
from pydantic import SecretStr
from starlette.status import HTTP_201_CREATED
from starlette.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT
from backend.api.external.middleware import require_permission
from backend.data.auth.base import APIAuthorizationInfo
from backend.data.model import APIKeyCredentials
from ..models import (
CredentialCreateRequest,
CredentialDeleteResponse,
CredentialInfo,
CredentialListResponse,
)
from ..models import CredentialCreateRequest, CredentialInfo, CredentialListResponse
from .helpers import creds_manager
logger = logging.getLogger(__name__)
@@ -89,13 +84,14 @@ async def create_credential(
@credentials_router.delete(
path="/credentials/{credential_id}",
summary="Delete a credential",
status_code=HTTP_204_NO_CONTENT,
)
async def delete_credential(
credential_id: str = Path(description="Credential ID"),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.DELETE_INTEGRATIONS)
),
) -> CredentialDeleteResponse:
) -> None:
"""
Delete an integration credential.
@@ -111,4 +107,3 @@ async def delete_credential(
)
await creds_manager.delete(auth.user_id, credential_id)
return CredentialDeleteResponse()

View File

@@ -16,6 +16,10 @@ from backend.api.external.middleware import require_auth, require_permission
from backend.api.features.store import cache as store_cache
from backend.api.features.store import db as store_db
from backend.api.features.store import media as store_media
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
@@ -46,7 +50,7 @@ marketplace_router = APIRouter()
# ============================================================================
# Endpoints - Read (authenticated)
# Agents
# ============================================================================
@@ -55,35 +59,29 @@ marketplace_router = APIRouter()
summary="List marketplace agents",
)
async def list_agents(
featured: bool = Query(default=False, description="Filter to featured agents only"),
featured: bool = Query(
default=False, description="Filter to only show featured agents"
),
creator: Optional[str] = Query(
default=None, description="Filter by creator username"
),
sorted_by: Optional[Literal["rating", "runs", "name", "updated_at"]] = Query(
default=None, description="Sort field"
),
search_query: Optional[str] = Query(default=None, description="Search query"),
category: Optional[str] = Query(default=None, description="Filter by category"),
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})",
search_query: Optional[str] = Query(
default=None, description="Literal + semantic search on names and descriptions"
),
# This data is public. We still require auth for access tracking and rate limits.
sorted_by: Optional[Literal["rating", "runs", "name", "updated_at"]] = Query(
default=None,
description="Property to sort results by. Ignored if search_query is provided.",
),
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),
) -> MarketplaceAgentListResponse:
"""
List agents available in the marketplace.
Supports filtering by featured status, creator, category, and search query.
Results can be sorted by rating, runs, name, or update time.
"""
"""List agents available in the marketplace, with optional filtering and sorting."""
result = await store_cache._get_cached_store_agents(
featured=featured,
creator=creator,
sorted_by=sorted_by,
sorted_by=StoreAgentsSortOptions(sorted_by) if sorted_by else None,
search_query=search_query,
category=category,
page=page,
@@ -101,17 +99,13 @@ async def list_agents(
@marketplace_router.get(
path="/agents/by-version/{version_id}",
summary="Get agent by store listing version ID",
summary="Get agent by listing version ID",
)
async def get_agent_by_version(
version_id: str = Path(description="Store listing version ID"),
# This data is public, but we still require auth for access tracking and rate limits
version_id: str,
auth: APIAuthorizationInfo = Security(require_auth),
) -> MarketplaceAgentDetails:
"""
Get detailed information about a marketplace agent by its store listing
version ID.
"""
"""Get details of a marketplace agent by its store listing version ID."""
try:
agent = await store_db.get_store_agent_by_version_id(version_id)
except Exception:
@@ -126,14 +120,11 @@ async def get_agent_by_version(
summary="Get agent details",
)
async def get_agent_details(
username: str = Path(description="Creator username"),
agent_name: str = Path(description="Agent slug/name"),
# This data is public. We still require auth for access tracking and rate limits.
username: str,
agent_name: str,
auth: APIAuthorizationInfo = Security(require_auth),
) -> MarketplaceAgentDetails:
"""
Get detailed information about a specific marketplace agent.
"""
"""Get details of a specific marketplace agent."""
username = urllib.parse.unquote(username).lower()
agent_name = urllib.parse.unquote(agent_name).lower()
@@ -144,70 +135,6 @@ async def get_agent_details(
return MarketplaceAgentDetails.from_internal(agent)
@marketplace_router.get(
path="/creators",
summary="List marketplace creators",
)
async def list_creators(
featured: bool = Query(
default=False, description="Filter to featured creators only"
),
search_query: Optional[str] = Query(default=None, description="Search query"),
sorted_by: Optional[Literal["agent_rating", "agent_runs", "num_agents"]] = Query(
default=None, description="Sort field"
),
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})",
),
# This data is public. We still require auth for access tracking and rate limits.
auth: APIAuthorizationInfo = Security(require_auth),
) -> MarketplaceCreatorsResponse:
"""
List creators on the marketplace.
Supports filtering by featured status and search query.
Results can be sorted by rating, runs, or number of agents.
"""
result = await store_cache._get_cached_store_creators(
featured=featured,
search_query=search_query,
sorted_by=sorted_by,
page=page,
page_size=page_size,
)
return MarketplaceCreatorsResponse(
creators=[MarketplaceCreatorDetails.from_internal(c) for c in result.creators],
page=result.pagination.current_page,
page_size=result.pagination.page_size,
total_count=result.pagination.total_items,
total_pages=result.pagination.total_pages,
)
@marketplace_router.get(
path="/creators/{username}",
summary="Get creator details",
)
async def get_creator_details(
username: str = Path(description="Creator username"),
# This data is public. We still require auth for access tracking and rate limits.
auth: APIAuthorizationInfo = Security(require_auth),
) -> MarketplaceCreatorDetails:
"""
Get detailed information about a specific marketplace creator.
"""
username = urllib.parse.unquote(username).lower()
creator = await store_cache._get_cached_creator_details(username=username)
return MarketplaceCreatorDetails.from_internal(creator)
@marketplace_router.post(
path="/agents/{username}/{agent_name}/add-to-library",
summary="Add a marketplace agent to your library",
@@ -220,11 +147,7 @@ async def add_agent_to_library(
require_permission(APIKeyPermission.READ_STORE, APIKeyPermission.WRITE_LIBRARY)
),
) -> LibraryAgent:
"""
Add a marketplace agent to the authenticated user's library.
If the agent is already in the library, returns the existing entry.
"""
"""Add a marketplace agent to the authenticated user's library."""
from backend.api.features.library import db as library_db
username = urllib.parse.unquote(username).lower()
@@ -243,7 +166,7 @@ async def add_agent_to_library(
# ============================================================================
# Endpoints - Search
# Search
# ============================================================================
@@ -257,14 +180,8 @@ async def search_marketplace(
default=None, description="Content types to filter by"
),
category: Optional[str] = Query(default=None, description="Filter by category"),
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})",
),
# This data is public, but we still require auth for access tracking and rate limits
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:
"""
@@ -305,7 +222,62 @@ async def search_marketplace(
# ============================================================================
# Endpoints - Profile
# Creators
# ============================================================================
@marketplace_router.get(
path="/creators",
summary="List marketplace creators",
)
async def list_creators(
featured: bool = Query(
default=False, description="Filter to featured creators only"
),
search_query: Optional[str] = Query(
default=None, description="Literal + semantic search on names and descriptions"
),
sorted_by: Optional[Literal["agent_rating", "agent_runs", "num_agents"]] = Query(
default=None, description="Sort field"
),
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),
) -> MarketplaceCreatorsResponse:
"""List or search marketplace creators."""
result = await store_cache._get_cached_store_creators(
featured=featured,
search_query=search_query,
sorted_by=StoreCreatorsSortOptions(sorted_by) if sorted_by else None,
page=page,
page_size=page_size,
)
return MarketplaceCreatorsResponse(
creators=[MarketplaceCreatorDetails.from_internal(c) for c in result.creators],
page=result.pagination.current_page,
page_size=result.pagination.page_size,
total_count=result.pagination.total_items,
total_pages=result.pagination.total_pages,
)
@marketplace_router.get(
path="/creators/{username}",
summary="Get creator details",
)
async def get_creator_details(
username: str,
auth: APIAuthorizationInfo = Security(require_auth),
) -> MarketplaceCreatorDetails:
"""Get details on a marketplace creator."""
username = urllib.parse.unquote(username).lower()
creator = await store_cache._get_cached_creator_details(username=username)
return MarketplaceCreatorDetails.from_internal(creator)
# ============================================================================
# Profile
# ============================================================================
@@ -318,9 +290,7 @@ async def get_profile(
require_permission(APIKeyPermission.READ_STORE)
),
) -> MarketplaceCreatorDetails:
"""
Get the authenticated user's marketplace profile.
"""
"""Get the authenticated user's marketplace profile."""
profile = await store_db.get_user_profile(auth.user_id)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
@@ -329,7 +299,7 @@ async def get_profile(
return MarketplaceCreatorDetails.from_internal(creator)
@marketplace_router.post(
@marketplace_router.patch(
path="/profile",
summary="Update my marketplace profile",
)
@@ -339,14 +309,10 @@ async def update_profile(
require_permission(APIKeyPermission.WRITE_STORE)
),
) -> MarketplaceUserProfile:
"""
Update the authenticated user's marketplace profile.
"""Update the authenticated user's marketplace profile."""
from backend.api.features.store.model import ProfileUpdateRequest
Creates a profile if one doesn't exist.
"""
from backend.api.features.store.model import Profile
profile = Profile(
profile = ProfileUpdateRequest(
name=request.name,
username=request.username,
description=request.description,
@@ -354,12 +320,12 @@ async def update_profile(
avatar_url=request.avatar_url,
)
creator = await store_db.update_profile(auth.user_id, profile)
return MarketplaceUserProfile.from_internal(creator)
updated_profile = await store_db.update_profile(auth.user_id, profile)
return MarketplaceUserProfile.from_internal(updated_profile)
# ============================================================================
# Endpoints - Submissions (CRUD)
# Submissions
# ============================================================================
@@ -371,20 +337,10 @@ async def list_submissions(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.READ_STORE)
),
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})",
),
page: int = Query(ge=1, default=1),
page_size: int = Query(ge=1, le=MAX_PAGE_SIZE, default=DEFAULT_PAGE_SIZE),
) -> MarketplaceAgentSubmissionsListResponse:
"""
List your marketplace submissions.
Returns all submissions you've created, including drafts, pending,
approved, and rejected submissions.
"""
"""List the authenticated user's marketplace listing submissions."""
result = await store_db.get_store_submissions(
user_id=auth.user_id,
page=page,
@@ -412,43 +368,71 @@ async def create_submission(
require_permission(APIKeyPermission.WRITE_STORE)
),
) -> MarketplaceAgentSubmission:
"""
Create a new marketplace submission.
This submits an agent for review to be published in the marketplace.
The submission will be in PENDING status until reviewed by the team.
"""
"""Submit a new marketplace listing for review."""
submission = await store_db.create_store_submission(
user_id=auth.user_id,
agent_id=request.graph_id,
agent_version=request.graph_version,
graph_id=request.graph_id,
graph_version=request.graph_version,
slug=request.slug,
name=request.name,
sub_heading=request.sub_heading,
description=request.description,
instructions=request.instructions,
categories=request.categories,
image_urls=request.image_urls,
video_url=request.video_url,
categories=request.categories,
agent_output_demo_url=request.agent_output_demo_url,
changes_summary=request.changes_summary or "Initial Submission",
recommended_schedule_cron=request.recommended_schedule_cron,
)
return MarketplaceAgentSubmission.from_internal(submission)
@marketplace_router.put(
path="/submissions/{version_id}",
summary="Edit a submission",
)
async def edit_submission(
request: MarketplaceAgentSubmissionEditRequest,
version_id: str = Path(description="Store listing version ID"),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.WRITE_STORE)
),
) -> MarketplaceAgentSubmission:
"""Update a pending marketplace listing submission."""
try:
submission = await store_db.edit_store_submission(
user_id=auth.user_id,
store_listing_version_id=version_id,
name=request.name,
sub_heading=request.sub_heading,
description=request.description,
image_urls=request.image_urls,
video_url=request.video_url,
agent_output_demo_url=request.agent_output_demo_url,
categories=request.categories,
changes_summary=request.changes_summary,
recommended_schedule_cron=request.recommended_schedule_cron,
instructions=request.instructions,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return MarketplaceAgentSubmission.from_internal(submission)
@marketplace_router.delete(
path="/submissions/{submission_id}",
summary="Delete a submission",
)
async def delete_submission(
submission_id: str = Path(description="Submission ID"),
submission_id: str,
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.WRITE_STORE)
),
) -> None:
"""
Delete a marketplace submission.
Only submissions in DRAFT status can be deleted.
"""
"""Delete a marketplace listing submission."""
success = await store_db.delete_store_submission(
user_id=auth.user_id,
submission_id=submission_id,
@@ -460,6 +444,11 @@ async def delete_submission(
)
# ============================================================================
# Submission Media
# ============================================================================
@marketplace_router.post(
path="/submissions/media",
summary="Upload submission media",
@@ -470,11 +459,7 @@ async def upload_submission_media(
require_permission(APIKeyPermission.WRITE_STORE)
),
) -> MarketplaceMediaUploadResponse:
"""
Upload an image or video for a marketplace submission.
Accepted types: JPEG, PNG, GIF, WebP, MP4, WebM. Max size: 10MB.
"""
"""Upload an image or video for a marketplace submission. Max size: 10MB."""
media_upload_limiter.check(auth.user_id)
max_size = 10 * 1024 * 1024 # 10MB limit for external API
@@ -498,38 +483,3 @@ async def upload_submission_media(
)
return MarketplaceMediaUploadResponse(url=url)
@marketplace_router.put(
path="/submissions/{version_id}",
summary="Edit a submission",
)
async def edit_submission(
request: MarketplaceAgentSubmissionEditRequest,
version_id: str = Path(description="Store listing version ID"),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.WRITE_STORE)
),
) -> MarketplaceAgentSubmission:
"""
Edit an existing marketplace submission.
"""
try:
submission = await store_db.edit_store_submission(
user_id=auth.user_id,
store_listing_version_id=version_id,
name=request.name,
sub_heading=request.sub_heading,
description=request.description,
image_urls=request.image_urls,
video_url=request.video_url,
agent_output_demo_url=request.agent_output_demo_url,
categories=request.categories,
changes_summary=request.changes_summary,
recommended_schedule_cron=request.recommended_schedule_cron,
instructions=request.instructions,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return MarketplaceAgentSubmission.from_internal(submission)

View File

@@ -249,13 +249,13 @@ class BlockInfo(BaseModel):
id: str
name: str
description: str
categories: list["BlockCategoryInfo"] = Field(default_factory=list)
contributors: list["BlockContributorInfo"] = Field(default_factory=list)
categories: list["BlockCategoryInfo"]
contributors: list["BlockContributorInfo"]
input_schema: dict[str, Any]
output_schema: dict[str, Any]
static_output: bool
ui_type: block_types.BlockType
costs: list["BlockCostInfo"] = Field(default_factory=list)
block_type: block_types.BlockType
costs: list["BlockCostInfo"]
@classmethod
def from_internal(cls, b: block_types.AnyBlockSchema) -> "BlockInfo":
@@ -273,7 +273,7 @@ class BlockInfo(BaseModel):
input_schema=b.input_schema.jsonschema(),
output_schema=b.output_schema.jsonschema(),
static_output=b.static_output,
ui_type=b.block_type,
block_type=b.block_type,
costs=[
BlockCostInfo(
cost_type=c.cost_type,
@@ -299,9 +299,10 @@ class BlockCostInfo(BaseModel):
description="Type of cost (e.g., 'run', 'byte', 'second')"
)
cost_filter: dict[str, Any] = Field(
default_factory=dict, description="Conditions for this cost"
description="Partial node input that, if it matches the input "
"for an execution of this block, applies this cost to it"
)
cost_amount: int = Field(description="Cost amount in credits")
cost_amount: int = Field(description="Cost (× $0.01) per {cost_type}")
class BlockContributorInfo(BaseModel):
@@ -322,12 +323,10 @@ class AgentRunSchedule(BaseModel):
graph_version: int
cron: str = Field(description="Cron expression for the schedule")
input_data: dict[str, Any] = Field(
default_factory=dict, description="Input data for scheduled executions"
description="Input data for scheduled executions"
)
next_run_time: Optional[datetime] = Field(
default=None, description="Next scheduled run time"
)
is_enabled: bool = Field(default=True, description="Whether schedule is enabled")
next_run_time: Optional[datetime]
is_enabled: bool
@classmethod
def from_internal(cls, job: GraphExecutionJobInfo) -> Self:
@@ -388,10 +387,10 @@ class LibraryAgent(BaseModel):
graph_version: int
name: str
description: str
is_favorite: bool = False
can_access_graph: bool = False
is_latest_version: bool = False
image_url: Optional[str] = None
is_favorite: bool
can_access_graph: bool
is_latest_version: bool
image_url: Optional[str]
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")
@@ -455,11 +454,11 @@ class LibraryFolder(BaseModel):
id: str
name: str
icon: Optional[str] = None
color: Optional[str] = None
parent_id: Optional[str] = None
agent_count: int = 0
subfolder_count: int = 0
icon: Optional[str]
color: Optional[str]
parent_id: Optional[str]
agent_count: int
subfolder_count: int
created_at: datetime
updated_at: datetime
@@ -483,11 +482,11 @@ class LibraryFolderTree(BaseModel):
id: str
name: str
icon: Optional[str] = None
color: Optional[str] = None
agent_count: int = 0
subfolder_count: int = 0
children: list["LibraryFolderTree"] = Field(default_factory=list)
icon: Optional[str]
color: Optional[str]
agent_count: int
subfolder_count: int
children: list["LibraryFolderTree"]
@classmethod
def from_internal(cls, f: _LibraryFolderTree) -> Self:
@@ -555,7 +554,7 @@ class AgentPreset(BaseModel):
name: str
description: str
is_active: bool
inputs: dict[str, Any] = Field(default_factory=dict)
inputs: dict[str, Any]
created_at: datetime
updated_at: datetime
@@ -642,11 +641,11 @@ class AgentGraphRun(BaseModel):
graph_version: int
status: RunStatus
started_at: datetime | None
ended_at: datetime | None = None
inputs: Optional[dict[str, Any]] = None
cost: int = Field(default=0, description="Cost in cents ($)")
duration: float = Field(default=0, description="Duration in seconds")
node_exec_count: int = Field(default=0, description="Number of nodes executed")
ended_at: datetime | None
inputs: Optional[dict[str, Any]]
cost: int = Field(description="Cost in cents ($)")
duration: float = Field(description="Duration in seconds")
node_exec_count: int = Field(description="Number of nodes executed")
@classmethod
def from_internal(cls, exec: GraphExecutionMeta) -> Self:
@@ -667,7 +666,7 @@ class AgentGraphRun(BaseModel):
class AgentGraphRunDetails(AgentGraphRun):
"""Detailed information about a run including outputs and node executions."""
outputs: Optional[dict[str, list[Any]]] = None
outputs: Optional[dict[str, list[Any]]]
node_executions: Optional[list[AgentNodeExecution]] = Field(
description="Individual node execution results; "
"may be omitted in case of permission restrictions"
@@ -708,15 +707,12 @@ class AgentNodeExecution(BaseModel):
node_id: str
status: RunStatus
input_data: dict[str, Any] = Field(
default_factory=dict, description="Input values keyed by pin name"
)
input_data: dict[str, Any] = Field(description="Input values keyed by pin name")
output_data: dict[str, list[Any]] = Field(
default_factory=dict,
description="Output values keyed by pin name, each with a list of results",
)
started_at: datetime | None = None
ended_at: datetime | None = None
started_at: datetime | None
ended_at: datetime | None
class AgentRunListResponse(PaginatedResponse):
@@ -748,12 +744,8 @@ class AgentRunReview(BaseModel):
graph_id: str
graph_version: int
payload: JsonValue = Field(description="Data to be reviewed")
instructions: Optional[str] = Field(
default=None, description="Instructions for the reviewer"
)
editable: bool = Field(
default=True, description="Whether the reviewer can edit the data"
)
instructions: Optional[str] = Field(description="Instructions for the reviewer")
editable: bool = Field(description="Whether the reviewer can edit the data")
status: AgentRunReviewStatus
created_at: datetime
@@ -828,10 +820,8 @@ class CreditTransaction(BaseModel):
amount: int = Field(description="Transaction amount (positive or negative)")
type: TransactionType
transaction_time: datetime
running_balance: Optional[int] = Field(
default=None, description="Balance after this transaction"
)
description: Optional[str] = None
running_balance: Optional[int] = Field(description="Balance after this transaction")
description: Optional[str]
@classmethod
def from_internal(cls, t: UserTransaction) -> Self:
@@ -861,10 +851,8 @@ class CredentialInfo(BaseModel):
id: str
provider: str = Field(description="Integration provider name")
title: Optional[str] = Field(
default=None, description="User-assigned title for this credential"
)
scopes: list[str] = Field(default_factory=list, description="Granted scopes")
title: Optional[str] = Field(description="User-assigned title for this credential")
scopes: list[str] = Field(description="Granted scopes")
@classmethod
def from_internal(cls, cred: Credentials) -> Self:
@@ -897,21 +885,12 @@ class CredentialCreateRequest(BaseModel):
api_key: str = Field(description="API key value")
class CredentialDeleteResponse(BaseModel):
"""Response after deleting a credential."""
deleted: bool = True
class CredentialRequirement(BaseModel):
"""A credential requirement for an agent (graph)."""
provider: str = Field(description="Required provider name")
required_scopes: list[str] = Field(
default_factory=list, description="Required scopes"
)
required_scopes: list[str] = Field(description="Required scopes")
matching_credentials: list[CredentialInfo] = Field(
default_factory=list,
description="User's credentials that match this requirement",
)
@@ -951,9 +930,9 @@ class MarketplaceAgent(BaseModel):
sub_heading: str
creator: str
creator_avatar: str
runs: int = Field(default=0, description="Number of times this agent has been run")
rating: float = Field(default=0.0, description="Average rating")
image_url: str = ""
runs: int = Field(description="Number of times this agent has been run")
rating: float = Field(description="Average rating")
image_url: str
@classmethod
def from_internal(cls, agent: StoreAgent) -> Self:
@@ -974,15 +953,17 @@ class MarketplaceAgentDetails(MarketplaceAgent):
"""Detailed information about a marketplace agent."""
store_listing_version_id: str
versions: list[int] = Field(
description="Available store listing versions (sequential; != graph version)",
)
instructions: Optional[str]
categories: list[str]
image_urls: list[str]
video_url: str = ""
versions: list[str] = Field(
description="Available store listing versions (sequential; != graph version)",
)
agent_graph_id: str
agent_graph_versions: list[str]
video_url: Optional[str]
agent_output_demo_url: str
recommended_schedule_cron: Optional[str]
graph_id: str
graph_versions: list[int]
last_updated: datetime
@classmethod
@@ -992,6 +973,7 @@ class MarketplaceAgentDetails(MarketplaceAgent):
return cls(
store_listing_version_id=agent.store_listing_version_id,
slug=agent.slug,
versions=[int(v) for v in agent.versions],
name=agent.agent_name,
description=agent.description,
sub_heading=agent.sub_heading,
@@ -1003,10 +985,11 @@ class MarketplaceAgentDetails(MarketplaceAgent):
rating=agent.rating,
image_url=agent.agent_image[0] if agent.agent_image else "",
image_urls=agent.agent_image,
video_url=agent.agent_video,
versions=agent.versions,
agent_graph_id=agent.graph_id,
agent_graph_versions=agent.graph_versions,
video_url=agent.agent_video or None,
agent_output_demo_url=agent.agent_output_demo,
recommended_schedule_cron=agent.recommended_schedule_cron or None,
graph_id=agent.graph_id,
graph_versions=[int(v) for v in agent.graph_versions],
last_updated=agent.last_updated,
)
@@ -1040,13 +1023,13 @@ class MarketplaceUserProfile(BaseModel):
class MarketplaceUserProfileUpdateRequest(BaseModel):
"""Request to update marketplace profile."""
"""Request to partially update marketplace profile."""
name: str = Field(description="Display name")
username: str = Field(description="Unique username")
description: str = Field(default="", description="Bio/description")
links: list[str] = Field(default_factory=list, description="Profile links")
avatar_url: str = Field(default="", description="Avatar image URL")
name: Optional[str] = Field(default=None, description="Display name")
username: Optional[str] = Field(default=None, description="Unique username")
description: Optional[str] = Field(default=None, description="Bio/description")
links: Optional[list[str]] = Field(default=None, description="Profile links")
avatar_url: Optional[str] = Field(default=None, description="Avatar image URL")
class MarketplaceCreatorDetails(MarketplaceUserProfile):
@@ -1087,46 +1070,48 @@ SubmissionStatus: TypeAlias = Literal["DRAFT", "PENDING", "APPROVED", "REJECTED"
class MarketplaceAgentSubmission(BaseModel):
"""A marketplace submission."""
listing_version_id: str
listing_version: int
graph_id: str
graph_version: int
slug: str
name: str
sub_heading: str
slug: str
description: str
instructions: Optional[str] = None
image_urls: list[str] = Field(default_factory=list)
date_submitted: datetime
instructions: Optional[str]
categories: list[str]
image_urls: list[str]
video_url: Optional[str]
agent_output_demo_url: Optional[str]
submitted_at: Optional[datetime]
status: SubmissionStatus
runs: int = Field(default=0)
rating: float = Field(default=0.0)
store_listing_version_id: Optional[str] = None
version: Optional[int] = None
review_comments: Optional[str] = None
reviewed_at: Optional[datetime] = None
video_url: Optional[str] = None
categories: list[str] = Field(default_factory=list)
run_count: int
rating: float
review_comments: Optional[str]
reviewed_at: Optional[datetime]
@classmethod
def from_internal(cls, sub: StoreSubmission) -> Self:
return cls(
graph_id=sub.agent_id,
graph_version=sub.agent_version,
listing_version_id=sub.listing_version_id,
listing_version=sub.listing_version,
graph_id=sub.graph_id,
graph_version=sub.graph_version,
slug=sub.slug,
name=sub.name,
sub_heading=sub.sub_heading,
slug=sub.slug,
description=sub.description,
instructions=sub.instructions,
categories=sub.categories,
image_urls=sub.image_urls,
date_submitted=sub.date_submitted,
video_url=sub.video_url,
agent_output_demo_url=sub.agent_output_demo_url,
submitted_at=sub.submitted_at,
status=sub.status.value,
runs=sub.runs,
rating=sub.rating,
store_listing_version_id=sub.store_listing_version_id,
version=sub.version,
run_count=sub.run_count,
rating=sub.review_avg_rating,
review_comments=sub.review_comments,
reviewed_at=sub.reviewed_at,
video_url=sub.video_url,
categories=sub.categories,
)
@@ -1141,7 +1126,15 @@ class MarketplaceAgentSubmissionCreateRequest(BaseModel):
sub_heading: str = Field(description="Short tagline")
image_urls: list[str] = Field(default_factory=list)
video_url: Optional[str] = None
agent_output_demo_url: Optional[str] = None
categories: list[str] = Field(default_factory=list)
instructions: Optional[str] = Field(default=None, description="Usage instructions")
changes_summary: Optional[str] = Field(
default="Initial Submission", description="Summary of changes"
)
recommended_schedule_cron: Optional[str] = Field(
default=None, description="Recommended cron schedule"
)
class MarketplaceAgentSubmissionsListResponse(PaginatedResponse):
@@ -1188,9 +1181,9 @@ class MarketplaceSearchResult(BaseModel):
content_type: SearchContentType
content_id: str
searchable_text: str
metadata: Optional[dict] = None
updated_at: Optional[datetime] = None
combined_score: Optional[float] = None
metadata: Optional[dict]
updated_at: Optional[datetime]
combined_score: Optional[float]
class MarketplaceSearchResponse(PaginatedResponse):

View File

@@ -916,7 +916,7 @@ async def get_user_profile(
async def update_profile(
user_id: str, profile: store_model.Profile
user_id: str, profile: store_model.ProfileUpdateRequest
) -> store_model.ProfileDetails:
"""
Update the store profile for a user or create a new one if it doesn't exist.
@@ -930,11 +930,6 @@ async def update_profile(
"""
logger.info(f"Updating profile for user {user_id} with data: {profile}")
try:
# Sanitize username to allow only letters, numbers, and hyphens
username = "".join(
c if c.isalpha() or c == "-" or c.isnumeric() else ""
for c in profile.username
).lower()
# Check if profile exists for the given user_id
existing_profile = await prisma.models.Profile.prisma().find_first(
where={"userId": user_id}
@@ -957,17 +952,26 @@ async def update_profile(
logger.debug(f"Updating existing profile for user {user_id}")
# Prepare update data, only including non-None values
update_data = {}
update_data: prisma.types.ProfileUpdateInput = {}
if profile.name is not None:
update_data["name"] = profile.name
update_data["name"] = profile.name.strip()
if profile.username is not None:
update_data["username"] = username
# Sanitize username to allow only letters, numbers, and hyphens
update_data["username"] = "".join(
c if c.isalpha() or c == "-" or c.isnumeric() else ""
for c in profile.username
).lower()
if profile.description is not None:
update_data["description"] = profile.description
update_data["description"] = profile.description.strip()
if profile.links is not None:
update_data["links"] = profile.links
update_data["links"] = [
# Filter out empty links
link
for _link in profile.links
if (link := _link.strip())
]
if profile.avatar_url is not None:
update_data["avatarUrl"] = profile.avatar_url
update_data["avatarUrl"] = profile.avatar_url.strip() or None
# Update the existing profile
updated_profile = await prisma.models.Profile.prisma().update(

View File

@@ -7,7 +7,7 @@ import pytest
from prisma import Prisma
from . import db
from .model import Profile
from .model import ProfileUpdateRequest
@pytest.fixture(autouse=True)
@@ -297,7 +297,7 @@ async def test_update_profile(mocker):
mock_profile_db.return_value.update = mocker.AsyncMock(return_value=mock_profile)
# Test data
profile = Profile(
profile = ProfileUpdateRequest(
name="Test Creator",
username="creator",
description="Test description",

View File

@@ -117,19 +117,24 @@ class StoreAgentDetails(pydantic.BaseModel):
)
class Profile(pydantic.BaseModel):
class ProfileUpdateRequest(pydantic.BaseModel):
"""Marketplace user profile (only attributes that the user can update)"""
username: str | None = None
name: str | None = None
description: str | None = None
avatar_url: str | None = None
links: list[str] | None = None
class ProfileDetails(pydantic.BaseModel):
"""Marketplace user profile (including read-only fields)"""
username: str
name: str
description: str
avatar_url: str | None
links: list[str]
class ProfileDetails(Profile):
"""Marketplace user profile (including read-only fields)"""
is_featured: bool
@classmethod

View File

@@ -54,7 +54,7 @@ async def get_profile(
dependencies=[Security(autogpt_libs.auth.requires_user)],
)
async def update_or_create_profile(
profile: store_model.Profile,
profile: store_model.ProfileUpdateRequest,
user_id: str = Security(autogpt_libs.auth.get_user_id),
) -> store_model.ProfileDetails:
"""Update the store profile for the authenticated user."""

View File

@@ -6277,7 +6277,7 @@
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Profile" }
"schema": { "$ref": "#/components/schemas/ProfileUpdateRequest" }
}
},
"required": true
@@ -11334,26 +11334,6 @@
],
"title": "PostmarkSubscriptionChangeWebhook"
},
"Profile": {
"properties": {
"username": { "type": "string", "title": "Username" },
"name": { "type": "string", "title": "Name" },
"description": { "type": "string", "title": "Description" },
"avatar_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Avatar Url"
},
"links": {
"items": { "type": "string" },
"type": "array",
"title": "Links"
}
},
"type": "object",
"required": ["username", "name", "description", "avatar_url", "links"],
"title": "Profile",
"description": "Marketplace user profile (only attributes that the user can update)"
},
"ProfileDetails": {
"properties": {
"username": { "type": "string", "title": "Username" },
@@ -11382,6 +11362,36 @@
"title": "ProfileDetails",
"description": "Marketplace user profile (including read-only fields)"
},
"ProfileUpdateRequest": {
"properties": {
"username": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Username"
},
"name": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Name"
},
"description": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Description"
},
"avatar_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Avatar Url"
},
"links": {
"anyOf": [
{ "items": { "type": "string" }, "type": "array" },
{ "type": "null" }
],
"title": "Links"
}
},
"type": "object",
"title": "ProfileUpdateRequest",
"description": "Marketplace user profile (only attributes that the user can update)"
},
"Provider": {
"properties": {
"name": {