Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/oss/src/apis/fastapi/tools/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# Tool Catalog
ToolCatalogAction,
ToolCatalogActionDetails,
ToolCatalogCategory,
ToolCatalogIntegration,
ToolCatalogIntegrationDetails,
ToolCatalogProvider,
Expand Down Expand Up @@ -60,6 +61,11 @@ class ToolCatalogIntegrationsResponse(BaseModel):
] = []


class ToolCatalogCategoriesResponse(BaseModel):
count: int = 0
categories: List[ToolCatalogCategory] = []


class ToolCatalogActionResponse(BaseModel):
count: int = 0
action: Optional[Union[ToolCatalogAction, ToolCatalogActionDetails]] = None
Expand Down
55 changes: 55 additions & 0 deletions api/oss/src/apis/fastapi/tools/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from oss.src.apis.fastapi.tools.models import (
ToolCatalogActionResponse,
ToolCatalogActionsResponse,
ToolCatalogCategoriesResponse,
ToolCatalogIntegrationResponse,
ToolCatalogIntegrationsResponse,
ToolCatalogProviderResponse,
Expand Down Expand Up @@ -173,6 +174,14 @@ def __init__(
response_model=ToolCatalogIntegrationsResponse,
response_model_exclude_none=True,
)
self.router.add_api_route(
"/catalog/providers/{provider_key}/categories/",
self.list_categories,
methods=["GET"],
operation_id="list_tool_categories",
response_model=ToolCatalogCategoriesResponse,
response_model_exclude_none=True,
)
self.router.add_api_route(
"/catalog/providers/{provider_key}/integrations/{integration_key}",
self.get_integration,
Expand Down Expand Up @@ -392,6 +401,7 @@ async def list_integrations(
*,
search: Optional[str] = Query(default=None),
sort_by: Optional[str] = Query(default=None),
category: Optional[str] = Query(default=None),
limit: Optional[int] = Query(default=None),
cursor: Optional[str] = Query(default=None),
full_details: bool = Query(default=False),
Expand All @@ -408,6 +418,7 @@ async def list_integrations(
"provider_key": provider_key,
"search": search,
"sort_by": sort_by,
"category": category,
"limit": limit,
"cursor": cursor,
"full_details": full_details,
Expand All @@ -425,6 +436,7 @@ async def list_integrations(
provider_key=provider_key,
search=search,
sort_by=sort_by,
category=category,
limit=limit,
cursor=cursor,
)
Expand All @@ -447,6 +459,49 @@ async def list_integrations(

return response

@intercept_exceptions()
@handle_adapter_exceptions()
async def list_categories(
self,
request: Request,
provider_key: str,
) -> ToolCatalogCategoriesResponse:
has_permission = await check_action_access(
user_uid=request.state.user_id,
project_id=request.state.project_id,
permission=Permission.VIEW_TOOLS,
)
if not has_permission:
raise FORBIDDEN_EXCEPTION

cache_key = {"provider_key": provider_key}
cached = await get_cache(
project_id=None, # catalog is global; not per-project
namespace="tools:catalog:categories",
key=cache_key,
model=ToolCatalogCategoriesResponse,
)
if cached:
return cached

categories = await self.tools_service.list_categories(
provider_key=provider_key,
)
response = ToolCatalogCategoriesResponse(
count=len(categories),
categories=categories,
)

await set_cache(
project_id=None, # catalog is global; not per-project
namespace="tools:catalog:categories",
key=cache_key,
value=response,
ttl=30 * 60, # 30 minutes — categories change rarely
)

return response

@intercept_exceptions()
@handle_adapter_exceptions()
async def get_integration(
Expand Down
7 changes: 7 additions & 0 deletions api/oss/src/core/gateway/catalog/dtos.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,10 @@ class CatalogIntegrationsPage(BaseModel):
integrations: List[CatalogIntegration] = []
next_cursor: Optional[str] = None
total: int = 0


class CatalogCategory(BaseModel):
"""A toolkit category the catalog can be filtered by (e.g. "developer-tools")."""

id: str
name: str
5 changes: 5 additions & 0 deletions api/oss/src/core/gateway/catalog/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import List, Optional

from oss.src.core.gateway.catalog.dtos import (
CatalogCategory,
CatalogIntegration,
CatalogIntegrationsPage,
CatalogProvider,
Expand All @@ -25,10 +26,14 @@ async def list_integrations(
*,
search: Optional[str] = None,
sort_by: Optional[str] = None,
category: Optional[str] = None,
limit: Optional[int] = None,
cursor: Optional[str] = None,
) -> CatalogIntegrationsPage: ...

@abstractmethod
async def list_categories(self) -> List[CatalogCategory]: ...

@abstractmethod
async def get_integration(
self,
Expand Down
54 changes: 54 additions & 0 deletions api/oss/src/core/gateway/catalog/providers/composio/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from oss.src.utils.logging import get_module_logger
from oss.src.core.gateway.catalog.dtos import (
CatalogAuthScheme,
CatalogCategory,
CatalogIntegration,
CatalogIntegrationsPage,
CatalogProvider,
Expand Down Expand Up @@ -117,6 +118,7 @@ async def list_integrations(
*,
search: Optional[str] = None,
sort_by: Optional[str] = None,
category: Optional[str] = None,
limit: Optional[int] = None,
cursor: Optional[str] = None,
) -> CatalogIntegrationsPage:
Expand All @@ -127,6 +129,8 @@ async def list_integrations(
params["search"] = search
if sort_by:
params["sort_by"] = sort_by
if category:
params["category"] = category
if cursor:
params["cursor"] = cursor

Expand Down Expand Up @@ -166,11 +170,61 @@ async def list_integrations(
total=total_items,
)

# How many top toolkits to sample when deriving the category nav, and how many categories to
# surface. Composio's /toolkits/categories returns ~800 raw tags (incl. junk like "tag1" and
# auto-slugged names no toolkit actually carries → empty filters), so it's unusable as a nav.
_CATEGORY_SAMPLE_SIZE = 200
_CATEGORY_LIMIT = 30

async def list_categories(self) -> List[CatalogCategory]:
# Derive a focused nav from the most-used toolkits' OWN category tags: every category comes
# from a real toolkit (so filtering by its id is guaranteed to return results), ranked by how
# many popular toolkits carry it. Self-maintaining — no hardcoded allowlist.
try:
resp = await self._client.get(
f"{self.api_url}/toolkits",
headers=self._headers(),
params={"limit": self._CATEGORY_SAMPLE_SIZE, "sort_by": "usage"},
timeout=30.0,
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as e:
raise AdapterError(
provider_key="composio",
operation="list_categories",
detail=composio_error_detail(e),
) from e

items_raw = data.get("items", []) if isinstance(data, dict) else data
counts: Dict[str, int] = {}
names: Dict[str, str] = {}
for item in items_raw or []:
meta = item.get("meta") or {} if isinstance(item, dict) else {}
for cat in meta.get("categories") or []:
if isinstance(cat, dict):
cid = cat.get("id") or cat.get("slug") or cat.get("name")
cname = cat.get("name") or cid
else:
cid = cname = cat
if not cid:
continue
cid = str(cid)
counts[cid] = counts.get(cid, 0) + 1
names.setdefault(cid, str(cname))

ranked = sorted(counts.items(), key=lambda kv: (-kv[1], names[kv[0]].lower()))
return [
CatalogCategory(id=cid, name=names[cid])
for cid, _ in ranked[: self._CATEGORY_LIMIT]
]


# ---------------------------------------------------------------------------
# Parsers
# ---------------------------------------------------------------------------


_AUTH_SCHEME_MAP: Dict[str, CatalogAuthScheme] = {
"oauth": CatalogAuthScheme.OAUTH,
"oauth2": CatalogAuthScheme.OAUTH,
Expand Down
11 changes: 11 additions & 0 deletions api/oss/src/core/gateway/catalog/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import List, Optional

from oss.src.core.gateway.catalog.dtos import (
CatalogCategory,
CatalogIntegration,
CatalogIntegrationsPage,
CatalogProvider,
Expand Down Expand Up @@ -49,17 +50,27 @@ async def list_integrations(
#
search: Optional[str] = None,
sort_by: Optional[str] = None,
category: Optional[str] = None,
limit: Optional[int] = None,
cursor: Optional[str] = None,
) -> CatalogIntegrationsPage:
adapter = self.adapter_registry.get(provider_key)
return await adapter.list_integrations(
search=search,
sort_by=sort_by,
category=category,
limit=limit,
cursor=cursor,
)

async def list_categories(
self,
*,
provider_key: str,
) -> List[CatalogCategory]:
adapter = self.adapter_registry.get(provider_key)
return await adapter.list_categories()

async def get_integration(
self,
*,
Expand Down
5 changes: 5 additions & 0 deletions api/oss/src/core/tools/dtos.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pydantic import BaseModel, Field

from oss.src.core.gateway.catalog.dtos import (
CatalogCategory,
CatalogIntegration,
CatalogProvider,
)
Expand Down Expand Up @@ -73,6 +74,10 @@ class ToolCatalogIntegrationDetails(ToolCatalogIntegration):
actions: Optional[List[ToolCatalogAction]] = None


class ToolCatalogCategory(CatalogCategory):
pass


class ToolCatalogProvider(CatalogProvider):
key: ToolProviderKind

Expand Down
13 changes: 13 additions & 0 deletions api/oss/src/core/tools/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ToolAuthScheme,
ToolCatalogActionDetails,
ToolCatalogActionsPage,
ToolCatalogCategory,
ToolCatalogIntegration,
ToolCatalogIntegrationsPage,
ToolCatalogProvider,
Expand Down Expand Up @@ -98,13 +99,15 @@ async def list_integrations(
#
search: Optional[str] = None,
sort_by: Optional[str] = None,
category: Optional[str] = None,
limit: Optional[int] = None,
cursor: Optional[str] = None,
) -> ToolCatalogIntegrationsPage:
page = await self.catalog_service.list_integrations(
provider_key=provider_key,
search=search,
sort_by=sort_by,
category=category,
limit=limit,
cursor=cursor,
)
Expand All @@ -118,6 +121,16 @@ async def list_integrations(
total=page.total,
)

async def list_categories(
self,
*,
provider_key: str,
) -> List[ToolCatalogCategory]:
categories = await self.catalog_service.list_categories(
provider_key=provider_key,
)
return [ToolCatalogCategory.model_validate(c.model_dump()) for c in categories]

async def get_integration(
self,
*,
Expand Down
Loading
Loading