Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 8 additions & 1 deletion api/oss/src/core/tools/dtos.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ def provider_connection_id(self) -> Optional[str]:
)
return None

@property
def is_no_auth(self) -> bool:
"""True for a no-auth toolkit connection (no Composio auth config/account)."""
return bool(
self.data and isinstance(self.data, dict) and self.data.get("no_auth")
)

@property
def is_active(self) -> bool:
"""Check if connection is active (not deleted)."""
Expand Down Expand Up @@ -228,7 +235,7 @@ class ToolExecutionRequest(BaseModel):

integration_key: str
action_key: str
provider_connection_id: str
provider_connection_id: Optional[str] = None # absent for no-auth toolkits
user_id: Optional[str] = None
arguments: Dict[str, Any] = {}

Expand Down
36 changes: 29 additions & 7 deletions api/oss/src/core/tools/providers/composio/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@
COMPOSIO_DEFAULT_API_URL = "https://backend.composio.dev/api/v3"


def _is_no_auth_toolkit(toolkit: Dict[str, Any]) -> bool:
"""A toolkit needs no auth when every auth_config_details entry is NO_AUTH.

Composio's GET /toolkits/{slug} reports a single ``{"mode": "NO_AUTH"}`` entry
for toolkits like ``codeinterpreter`` and the ``composio`` meta-toolkit.
"""
details = toolkit.get("auth_config_details") or []
if not details:
return False
return all((detail.get("mode") or "").upper() == "NO_AUTH" for detail in details)


class ComposioToolsAdapter(ComposioCatalogClient, ToolsGatewayInterface):
"""Composio V3 API adapter — uses httpx directly (no SDK).

Expand Down Expand Up @@ -199,15 +211,25 @@ async def initiate_connection(
detail=str(e),
) from e

# Step 2: create an auth config for this integration.
# api_key → use_custom_auth; Composio's redirect UI collects the credentials.
# oauth / None → use_composio_managed_auth.
log.info(
"initiate_connection: integration_key=%s auth_scheme=%r",
integration_key,
auth_scheme,
)

# No-auth toolkits (e.g. codeinterpreter) reject auth-config creation with a
# 400. They need neither an auth config nor a connected account — their tools
# execute directly. Return a marked connection the service persists as valid.
if _is_no_auth_toolkit(toolkit):
return ToolConnectionResponse(
provider_connection_id="",
redirect_url=None,
connection_data={"no_auth": True},
)

# Step 2: create an auth config for this integration.
# api_key → use_custom_auth; Composio's redirect UI collects the credentials.
# oauth / None → use_composio_managed_auth.
if auth_scheme == "api_key":
# Derive Composio authScheme from toolkit's auth_config_details.
# Fall back to "API_KEY" as the common default.
Expand Down Expand Up @@ -388,10 +410,10 @@ async def execute(
action_key=request.action_key,
)

payload: Dict[str, Any] = {
"arguments": request.arguments,
"connected_account_id": request.provider_connection_id,
}
payload: Dict[str, Any] = {"arguments": request.arguments}
# No-auth toolkits run without a connected account; only send the id when set.
if request.provider_connection_id:
payload["connected_account_id"] = request.provider_connection_id
if request.user_id:
payload["user_id"] = request.user_id

Expand Down
21 changes: 19 additions & 2 deletions api/oss/src/core/tools/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,16 @@ async def create_connection(
data["project_id"] = str(project_id)
connection_create.data = data # type: ignore[assignment]

# Connection validity is server-owned, never client-supplied. An auth-backed
# connection is not valid until its flow completes (the OAuth callback flips
# is_valid). A no-auth toolkit has no flow, so the server marks it valid up front,
# but only after the adapter confirmed it is no-auth. Drop any client-sent flags so a
# caller cannot mark a pending OAuth connection valid.
connection_create.flags = { # type: ignore[assignment]
"is_active": True,
"is_valid": bool(data.get("no_auth")),
}

# Persist locally
return await self.tools_dao.create_connection(
project_id=project_id,
Expand Down Expand Up @@ -347,6 +357,11 @@ async def refresh_connection(
connection_id=str(connection_id),
)

# A no-auth connection has no provider-side authorization to re-link, so refresh is a
# no-op. Return it unchanged rather than reporting it missing.
if conn.is_no_auth:
return conn

if not conn.provider_connection_id:
raise ConnectionNotFoundError(
connection_id=str(connection_id),
Expand Down Expand Up @@ -408,7 +423,7 @@ async def execute_tool(
provider_key: str,
integration_key: str,
action_key: str,
provider_connection_id: str,
provider_connection_id: Optional[str] = None,
user_id: Optional[str] = None,
arguments: Dict[str, Any],
) -> ToolExecutionResponse:
Expand Down Expand Up @@ -473,7 +488,9 @@ async def resolve_connection_by_slug(
detail="Please refresh the connection.",
)

if not connection.provider_connection_id:
# No-auth toolkits have no provider-side connected account; the missing id is
# expected and execution runs without one.
if not connection.is_no_auth and not connection.provider_connection_id:
raise ConnectionNotFoundError(
provider_key=provider_key,
integration_key=integration_key,
Expand Down
Loading
Loading