chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
from .auth_configs import AuthConfigs
|
||||
from .connected_accounts import ConnectedAccounts
|
||||
from .custom_tool import ExperimentalToolkit
|
||||
from .custom_tool_types import (
|
||||
CustomTool,
|
||||
RegisteredCustomTool,
|
||||
RegisteredCustomToolkit,
|
||||
SessionContext,
|
||||
)
|
||||
from .experimental import ExperimentalAPI
|
||||
from .mcp import MCP
|
||||
from .tool_router import ToolRouter
|
||||
from .tool_router_constants import SESSION_PRESET_DIRECT_TOOLS
|
||||
from .tool_router_session import ToolRouterSession
|
||||
from .tool_router_session_delete import ToolRouterSessionDeleteResponse
|
||||
from .tool_router_session_files import RemoteFile, ToolRouterSessionFilesMount
|
||||
from .toolkits import Toolkits
|
||||
from .tools import Tools
|
||||
from .triggers import Triggers
|
||||
from .webhook_events import (
|
||||
ConnectionExpiredEvent,
|
||||
ConnectionState,
|
||||
ConnectionStatusEnum,
|
||||
SingleConnectedAccountDetailedResponse,
|
||||
WebhookConnectionMetadata,
|
||||
WebhookEvent,
|
||||
WebhookEventType,
|
||||
is_connection_expired_event,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthConfigs",
|
||||
"ConnectedAccounts",
|
||||
"ConnectionExpiredEvent",
|
||||
"ConnectionState",
|
||||
"ConnectionStatusEnum",
|
||||
"CustomTool",
|
||||
"ExperimentalAPI",
|
||||
"ExperimentalToolkit",
|
||||
"MCP",
|
||||
"RegisteredCustomTool",
|
||||
"RegisteredCustomToolkit",
|
||||
"RemoteFile",
|
||||
"SessionContext",
|
||||
"SESSION_PRESET_DIRECT_TOOLS",
|
||||
"SingleConnectedAccountDetailedResponse",
|
||||
"ToolRouter",
|
||||
"ToolRouterSession",
|
||||
"ToolRouterSessionDeleteResponse",
|
||||
"ToolRouterSessionFilesMount",
|
||||
"Toolkits",
|
||||
"Tools",
|
||||
"Triggers",
|
||||
"WebhookConnectionMetadata",
|
||||
"WebhookEvent",
|
||||
"WebhookEventType",
|
||||
"is_connection_expired_event",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,714 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import typing as t
|
||||
|
||||
import typing_extensions as te
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from .tools import Tool, ToolExecutionResponse, tool_execute_params
|
||||
|
||||
|
||||
# TODO: Maybe use `te.Unpack` in tools.execute?
|
||||
class ToolExecuteParams(te.TypedDict):
|
||||
allow_tracing: te.NotRequired[t.Optional[bool]]
|
||||
arguments: t.Dict[str, t.Optional[t.Any]]
|
||||
connected_account_id: te.NotRequired[str]
|
||||
custom_auth_params: te.NotRequired["tool_execute_params.CustomAuthParams"]
|
||||
custom_connection_data: te.NotRequired["tool_execute_params.CustomConnectionData"]
|
||||
entity_id: te.NotRequired[str]
|
||||
text: te.NotRequired[str]
|
||||
user_id: te.NotRequired[str]
|
||||
version: te.NotRequired[str]
|
||||
dangerously_skip_version_check: te.NotRequired[t.Optional[bool]]
|
||||
|
||||
|
||||
ModifierInOut = t.Union["ToolExecuteParams", "ToolExecutionResponse", "Tool"]
|
||||
|
||||
|
||||
class BeforeExecute(t.Protocol):
|
||||
"""
|
||||
A modifier that is called before the tool is executed.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
params: ToolExecuteParams,
|
||||
) -> ToolExecuteParams: ...
|
||||
|
||||
|
||||
class AfterExecute(t.Protocol):
|
||||
"""
|
||||
A modifier that is called after the tool is executed.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
response: ToolExecutionResponse,
|
||||
) -> ToolExecutionResponse: ...
|
||||
|
||||
|
||||
class SchemaModifier(t.Protocol):
|
||||
"""
|
||||
A modifier that is called to modify the schema of the tool.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
schema: "Tool",
|
||||
) -> "Tool": ...
|
||||
|
||||
|
||||
class BeforeFileUploadCallable(t.Protocol):
|
||||
"""Legacy positional form of the ``before_file_upload`` hook.
|
||||
|
||||
``(path, tool, toolkit) -> str | bool``. Still supported for back-compat,
|
||||
but new code should use :class:`BeforeFileUploadContextCallable` so it can
|
||||
discriminate local paths from URLs via ``context["source"]``.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
path: str,
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
) -> t.Union[str, bool]: ...
|
||||
|
||||
|
||||
class BeforeFileUploadContext(te.TypedDict):
|
||||
"""Context passed to the new-form ``before_file_upload`` hook.
|
||||
|
||||
- ``path``: the local filesystem path for ``source="path"``, or the URL
|
||||
string for ``source="url"``.
|
||||
- ``source``: discriminator — ``"path"`` for local paths, ``"url"`` for
|
||||
``http(s)://...`` inputs. Mirrors the TypeScript SDK's ``source`` field
|
||||
(TS additionally emits ``"file"`` for ``File`` objects; Python has no
|
||||
equivalent runtime type).
|
||||
- ``tool`` / ``toolkit``: slugs of the tool being executed.
|
||||
"""
|
||||
|
||||
path: str
|
||||
source: te.Literal["path", "url"]
|
||||
tool: str
|
||||
toolkit: str
|
||||
|
||||
|
||||
class BeforeFileUploadContextCallable(t.Protocol):
|
||||
"""Preferred form of the ``before_file_upload`` hook.
|
||||
|
||||
Takes a single :class:`BeforeFileUploadContext` argument and returns either
|
||||
a new path/URL string, or ``False`` to abort the upload.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
context: BeforeFileUploadContext,
|
||||
) -> t.Union[str, bool]: ...
|
||||
|
||||
|
||||
BeforeFileUploadLike = t.Union[
|
||||
BeforeFileUploadCallable,
|
||||
BeforeFileUploadContextCallable,
|
||||
]
|
||||
"""Either form of ``before_file_upload``. Adapted internally."""
|
||||
|
||||
|
||||
def _count_positional_params(fn: t.Callable) -> int:
|
||||
"""Return the number of positional (or positional-or-keyword) params, or
|
||||
-1 if the signature can't be introspected (e.g. builtins)."""
|
||||
import inspect
|
||||
|
||||
try:
|
||||
sig = inspect.signature(fn)
|
||||
except (TypeError, ValueError):
|
||||
return -1
|
||||
return sum(
|
||||
1
|
||||
for p in sig.parameters.values()
|
||||
if p.kind
|
||||
in (
|
||||
inspect.Parameter.POSITIONAL_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _adapt_before_file_upload(
|
||||
hook: BeforeFileUploadLike,
|
||||
) -> BeforeFileUploadContextCallable:
|
||||
"""Normalise a user-supplied hook to the context-object form.
|
||||
|
||||
A hook declared with exactly 3 positional parameters is treated as the
|
||||
legacy ``(path, tool, toolkit)`` form; anything else (typically a single
|
||||
positional ``context`` parameter) is treated as the new form.
|
||||
"""
|
||||
if _count_positional_params(hook) == 3:
|
||||
legacy = t.cast(BeforeFileUploadCallable, hook)
|
||||
|
||||
def wrap(context: BeforeFileUploadContext) -> t.Union[str, bool]:
|
||||
return legacy(context["path"], context["tool"], context["toolkit"])
|
||||
|
||||
return wrap
|
||||
return t.cast(BeforeFileUploadContextCallable, hook)
|
||||
|
||||
|
||||
ModifierSlug: t.TypeAlias = str
|
||||
AfterExecuteModifierL: t.TypeAlias = t.Literal["after_execute"]
|
||||
BeforeExecuteModifierL: t.TypeAlias = t.Literal["before_execute"]
|
||||
SchemaModifierL: t.TypeAlias = t.Literal["schema"]
|
||||
BeforeFileUploadModifierL: t.TypeAlias = t.Literal["before_file_upload"]
|
||||
|
||||
|
||||
class Modifier:
|
||||
def __init__(
|
||||
self,
|
||||
modifier: t.Optional[
|
||||
AfterExecute
|
||||
| BeforeExecute
|
||||
| SchemaModifier
|
||||
| BeforeExecuteMeta
|
||||
| AfterExecuteMeta
|
||||
| BeforeFileUploadCallable
|
||||
| BeforeFileUploadContextCallable
|
||||
],
|
||||
type_: (
|
||||
AfterExecuteModifierL
|
||||
| BeforeExecuteModifierL
|
||||
| SchemaModifierL
|
||||
| AfterExecuteMetaModifierL
|
||||
| BeforeExecuteMetaModifierL
|
||||
| BeforeFileUploadModifierL
|
||||
),
|
||||
tools: t.List[str],
|
||||
toolkits: t.List[str],
|
||||
) -> None:
|
||||
self.modifier = modifier
|
||||
self.tools = tools
|
||||
self.type = type_
|
||||
self.toolkits = toolkits
|
||||
|
||||
def apply(
|
||||
self,
|
||||
toolkit: str,
|
||||
tool: str,
|
||||
data: ModifierInOut,
|
||||
modifer_type: str,
|
||||
) -> ModifierInOut:
|
||||
if self.modifier is None:
|
||||
raise ValueError("Modifier is not provided")
|
||||
|
||||
# If no tools or toolkits are provided, apply the modifier to all tools
|
||||
if (
|
||||
self.type == modifer_type
|
||||
and len(self.tools) == 0
|
||||
and len(self.toolkits) == 0
|
||||
):
|
||||
return self.modifier(tool, toolkit, data) # type: ignore
|
||||
|
||||
# If the modifier is not the same type, or the slug is not in the tools or
|
||||
# toolkits, return the data as is
|
||||
if (
|
||||
self.type != modifer_type
|
||||
or tool not in self.tools
|
||||
and toolkit not in self.toolkits
|
||||
):
|
||||
return data
|
||||
|
||||
# Apply the modifier to the data
|
||||
return self.modifier(tool, toolkit, data) # type: ignore
|
||||
|
||||
|
||||
@t.overload
|
||||
def after_execute(
|
||||
modifier: t.Optional[AfterExecute],
|
||||
) -> Modifier: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def after_execute(
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> t.Callable[[AfterExecute], Modifier]: ...
|
||||
|
||||
|
||||
def after_execute(
|
||||
modifier: t.Optional[AfterExecute] = None,
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> Modifier | t.Callable[[AfterExecute], Modifier]:
|
||||
if modifier is not None:
|
||||
return Modifier(
|
||||
modifier=modifier,
|
||||
type_="after_execute",
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
)
|
||||
|
||||
if tools is not None or toolkits is not None:
|
||||
return t.cast(
|
||||
t.Callable[[AfterExecute], Modifier],
|
||||
functools.partial(
|
||||
after_execute,
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
),
|
||||
)
|
||||
|
||||
raise ValueError("Either tools or toolkits must be provided")
|
||||
|
||||
|
||||
@t.overload
|
||||
def before_execute(modifier: t.Optional[BeforeExecute]) -> Modifier: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def before_execute(
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> t.Callable[[BeforeExecute], Modifier]: ...
|
||||
|
||||
|
||||
def before_execute(
|
||||
modifier: t.Optional[BeforeExecute] = None,
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> Modifier | t.Callable[[BeforeExecute], Modifier]:
|
||||
if modifier is not None:
|
||||
return Modifier(
|
||||
modifier=modifier,
|
||||
type_="before_execute",
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
)
|
||||
|
||||
if tools is not None or toolkits is not None:
|
||||
return t.cast(
|
||||
t.Callable[[BeforeExecute], Modifier],
|
||||
functools.partial(
|
||||
before_execute,
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
),
|
||||
)
|
||||
|
||||
raise ValueError("Either tools or toolkits must be provided")
|
||||
|
||||
|
||||
@t.overload
|
||||
def before_file_upload(modifier: t.Optional[BeforeFileUploadLike]) -> Modifier: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def before_file_upload(
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> t.Callable[[BeforeFileUploadLike], Modifier]: ...
|
||||
|
||||
|
||||
def before_file_upload(
|
||||
modifier: t.Optional[BeforeFileUploadLike] = None,
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> Modifier | t.Callable[[BeforeFileUploadLike], Modifier]:
|
||||
"""
|
||||
Build a ``Modifier`` for the file-upload hook (same scoping pattern as
|
||||
:func:`before_execute`).
|
||||
|
||||
Your callable may take **either**:
|
||||
|
||||
- a single ``context`` argument (:class:`BeforeFileUploadContext`) — the
|
||||
preferred form, exposes ``context["source"]`` (``"path"`` or ``"url"``),
|
||||
or
|
||||
- three positional arguments ``(path, tool, toolkit)`` — legacy form, kept
|
||||
for back-compat.
|
||||
|
||||
Return a new path/URL string to substitute, or ``False`` to abort the
|
||||
upload (raises :class:`~composio.exceptions.FileUploadAbortedError`).
|
||||
|
||||
Pass the returned ``Modifier`` in ``modifiers=[...]`` on
|
||||
:meth:`composio.core.models.tools.Tools.execute` or ``tools.get``. Multiple
|
||||
such modifiers are composed in list order.
|
||||
"""
|
||||
if modifier is not None:
|
||||
return Modifier(
|
||||
modifier=modifier,
|
||||
type_="before_file_upload",
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
)
|
||||
|
||||
if tools is not None or toolkits is not None:
|
||||
return t.cast(
|
||||
t.Callable[[BeforeFileUploadLike], Modifier],
|
||||
functools.partial(
|
||||
before_file_upload,
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
),
|
||||
)
|
||||
|
||||
raise ValueError("Either tools or toolkits must be provided")
|
||||
|
||||
|
||||
@t.overload
|
||||
def schema_modifier(modifier: t.Optional[SchemaModifier]) -> Modifier: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def schema_modifier(
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> t.Callable[[SchemaModifier], Modifier]: ...
|
||||
|
||||
|
||||
def schema_modifier(
|
||||
modifier: t.Optional[SchemaModifier] = None,
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> Modifier | t.Callable[[SchemaModifier], Modifier]:
|
||||
if modifier is not None:
|
||||
return Modifier(
|
||||
modifier=modifier,
|
||||
type_="schema",
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
)
|
||||
|
||||
if tools is not None or toolkits is not None:
|
||||
return t.cast(
|
||||
t.Callable[[SchemaModifier], Modifier],
|
||||
functools.partial(
|
||||
schema_modifier,
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
),
|
||||
)
|
||||
|
||||
raise ValueError("Either tools or toolkits must be provided")
|
||||
|
||||
|
||||
@t.overload
|
||||
def before_execute_meta(modifier: t.Optional[BeforeExecuteMeta]) -> Modifier: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def before_execute_meta(
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> t.Callable[[BeforeExecuteMeta], Modifier]: ...
|
||||
|
||||
|
||||
def before_execute_meta(
|
||||
modifier: t.Optional[BeforeExecuteMeta] = None,
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> Modifier | t.Callable[[BeforeExecuteMeta], Modifier]:
|
||||
if modifier is not None:
|
||||
return Modifier(
|
||||
modifier=modifier,
|
||||
type_="before_execute_meta",
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
)
|
||||
|
||||
if tools is not None or toolkits is not None:
|
||||
return t.cast(
|
||||
t.Callable[[BeforeExecuteMeta], Modifier],
|
||||
functools.partial(
|
||||
before_execute_meta,
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
),
|
||||
)
|
||||
|
||||
raise ValueError("Either tools or toolkits must be provided")
|
||||
|
||||
|
||||
@t.overload
|
||||
def after_execute_meta(modifier: t.Optional[AfterExecuteMeta]) -> Modifier: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def after_execute_meta(
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> t.Callable[[AfterExecuteMeta], Modifier]: ...
|
||||
|
||||
|
||||
def after_execute_meta(
|
||||
modifier: t.Optional[AfterExecuteMeta] = None,
|
||||
*,
|
||||
tools: t.Optional[t.List[str]] = None,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
) -> Modifier | t.Callable[[AfterExecuteMeta], Modifier]:
|
||||
if modifier is not None:
|
||||
return Modifier(
|
||||
modifier=modifier,
|
||||
type_="after_execute_meta",
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
)
|
||||
|
||||
if tools is not None or toolkits is not None:
|
||||
return t.cast(
|
||||
t.Callable[[AfterExecuteMeta], Modifier],
|
||||
functools.partial(
|
||||
after_execute_meta,
|
||||
tools=tools or [],
|
||||
toolkits=toolkits or [],
|
||||
),
|
||||
)
|
||||
|
||||
raise ValueError("Either tools or toolkits must be provided")
|
||||
|
||||
|
||||
Modifiers = t.List[Modifier]
|
||||
|
||||
|
||||
def merge_before_file_upload(
|
||||
modifiers: t.Optional[Modifiers],
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
) -> t.Optional[BeforeFileUploadContextCallable]:
|
||||
"""Compose ``before_file_upload``-type :class:`Modifier`\\ s for this *tool* / *toolkit*.
|
||||
|
||||
Scoping matches :class:`Modifier` (empty ``tools`` and ``toolkits`` = all tools).
|
||||
|
||||
Each user-supplied hook is adapted to the context form by
|
||||
:func:`_adapt_before_file_upload`, so legacy 3-arg callables keep working
|
||||
while new-form callables receive the full :class:`BeforeFileUploadContext`
|
||||
(including ``source``).
|
||||
"""
|
||||
to_chain = [
|
||||
m
|
||||
for m in (modifiers or [])
|
||||
if m.type == "before_file_upload" and m.modifier is not None
|
||||
]
|
||||
if not to_chain:
|
||||
return None
|
||||
|
||||
def _applies(m: Modifier) -> bool:
|
||||
if len(m.tools) == 0 and len(m.toolkits) == 0:
|
||||
return True
|
||||
return tool in m.tools or toolkit in m.toolkits
|
||||
|
||||
adapted_chain = [
|
||||
(m, _adapt_before_file_upload(t.cast(BeforeFileUploadLike, m.modifier)))
|
||||
for m in to_chain
|
||||
]
|
||||
|
||||
def combined(context: BeforeFileUploadContext) -> t.Union[str, bool]:
|
||||
p: str = context["path"]
|
||||
for m, hook in adapted_chain:
|
||||
if not _applies(m):
|
||||
continue
|
||||
# Preserve source/tool/toolkit while forwarding the (possibly
|
||||
# rewritten) path to the next hook.
|
||||
next_ctx: BeforeFileUploadContext = {
|
||||
"path": p,
|
||||
"source": context["source"],
|
||||
"tool": context["tool"],
|
||||
"toolkit": context["toolkit"],
|
||||
}
|
||||
out = hook(next_ctx)
|
||||
if out is False:
|
||||
return False
|
||||
if isinstance(out, str):
|
||||
p = out
|
||||
return p
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
@t.overload
|
||||
def apply_modifier_by_type(
|
||||
modifiers: Modifiers,
|
||||
toolkit: str,
|
||||
tool: str,
|
||||
*,
|
||||
type: BeforeExecuteModifierL,
|
||||
request: ToolExecuteParams,
|
||||
) -> ToolExecuteParams: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def apply_modifier_by_type(
|
||||
modifiers: Modifiers,
|
||||
toolkit: str,
|
||||
tool: str,
|
||||
*,
|
||||
type: AfterExecuteModifierL,
|
||||
response: "ToolExecutionResponse",
|
||||
) -> "ToolExecutionResponse": ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def apply_modifier_by_type(
|
||||
modifiers: Modifiers,
|
||||
toolkit: str,
|
||||
tool: str,
|
||||
*,
|
||||
type: t.Literal["schema"],
|
||||
schema: "Tool",
|
||||
) -> "Tool": ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def apply_modifier_by_type(
|
||||
modifiers: Modifiers,
|
||||
toolkit: str,
|
||||
tool: str,
|
||||
*,
|
||||
type: BeforeExecuteMetaModifierL,
|
||||
session_id: str,
|
||||
params: t.Dict[str, t.Any],
|
||||
) -> t.Dict[str, t.Any]: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def apply_modifier_by_type(
|
||||
modifiers: Modifiers,
|
||||
toolkit: str,
|
||||
tool: str,
|
||||
*,
|
||||
type: AfterExecuteMetaModifierL,
|
||||
session_id: str,
|
||||
response: "ToolExecutionResponse",
|
||||
) -> "ToolExecutionResponse": ...
|
||||
|
||||
|
||||
def apply_modifier_by_type(
|
||||
modifiers: Modifiers,
|
||||
toolkit: str,
|
||||
tool: str,
|
||||
*,
|
||||
type: t.Literal[
|
||||
"before_execute",
|
||||
"after_execute",
|
||||
"schema",
|
||||
"before_execute_meta",
|
||||
"after_execute_meta",
|
||||
],
|
||||
schema: t.Optional["Tool"] = None,
|
||||
request: t.Optional["ToolExecuteParams"] = None,
|
||||
response: t.Optional["ToolExecutionResponse"] = None,
|
||||
session_id: t.Optional[str] = None,
|
||||
params: t.Optional[t.Dict[str, t.Any]] = None,
|
||||
) -> t.Union[ModifierInOut, t.Dict[str, t.Any]]:
|
||||
"""Apply a modifier to a tool."""
|
||||
# For meta modifiers, we handle them differently
|
||||
if type in ("before_execute_meta", "after_execute_meta"):
|
||||
if session_id is None:
|
||||
raise ValueError("session_id is required for meta modifiers")
|
||||
|
||||
if type == "before_execute_meta":
|
||||
if params is None:
|
||||
raise ValueError("params is required for before_execute_meta")
|
||||
result_params: t.Dict[str, t.Any] = params
|
||||
for modifier in modifiers:
|
||||
if modifier.type == type:
|
||||
# Check if modifier should be applied
|
||||
should_apply = (
|
||||
(len(modifier.tools) == 0 and len(modifier.toolkits) == 0)
|
||||
or tool in modifier.tools
|
||||
or toolkit in modifier.toolkits
|
||||
)
|
||||
|
||||
if should_apply and modifier.modifier is not None:
|
||||
result_params = t.cast(BeforeExecuteMeta, modifier.modifier)(
|
||||
tool, toolkit, session_id, result_params
|
||||
)
|
||||
return result_params
|
||||
else: # after_execute_meta
|
||||
if response is None:
|
||||
raise ValueError("response is required for after_execute_meta")
|
||||
result_response: "ToolExecutionResponse" = response
|
||||
for modifier in modifiers:
|
||||
if modifier.type == type:
|
||||
# Check if modifier should be applied
|
||||
should_apply = (
|
||||
(len(modifier.tools) == 0 and len(modifier.toolkits) == 0)
|
||||
or tool in modifier.tools
|
||||
or toolkit in modifier.toolkits
|
||||
)
|
||||
|
||||
if should_apply and modifier.modifier is not None:
|
||||
result_response = t.cast(AfterExecuteMeta, modifier.modifier)(
|
||||
tool, toolkit, session_id, result_response
|
||||
)
|
||||
return result_response
|
||||
|
||||
# For regular modifiers
|
||||
result: ModifierInOut
|
||||
if schema is not None:
|
||||
result = schema
|
||||
elif request is not None:
|
||||
result = request
|
||||
elif response is not None:
|
||||
result = response
|
||||
else:
|
||||
raise ValueError("No data provided")
|
||||
|
||||
for modifier in modifiers:
|
||||
result = modifier.apply(
|
||||
toolkit=toolkit,
|
||||
tool=tool,
|
||||
data=result,
|
||||
modifer_type=type,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class BeforeExecuteMeta(t.Protocol):
|
||||
"""
|
||||
A modifier that is called before the meta tool is executed in a session context.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
session_id: str,
|
||||
params: t.Dict[str, t.Any],
|
||||
) -> t.Dict[str, t.Any]: ...
|
||||
|
||||
|
||||
class AfterExecuteMeta(t.Protocol):
|
||||
"""
|
||||
A modifier that is called after the meta tool is executed in a session context.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
session_id: str,
|
||||
response: ToolExecutionResponse,
|
||||
) -> ToolExecutionResponse: ...
|
||||
|
||||
|
||||
AfterExecuteMetaModifierL: t.TypeAlias = t.Literal["after_execute_meta"]
|
||||
BeforeExecuteMetaModifierL: t.TypeAlias = t.Literal["before_execute_meta"]
|
||||
|
||||
|
||||
class ToolOptions(te.TypedDict):
|
||||
modify_schema: te.NotRequired[
|
||||
t.Dict[ModifierSlug, AfterExecute | BeforeExecute | SchemaModifier]
|
||||
]
|
||||
@@ -0,0 +1,170 @@
|
||||
import atexit
|
||||
import functools
|
||||
import queue as q
|
||||
import threading as tr
|
||||
import time
|
||||
import typing as t
|
||||
|
||||
import httpx
|
||||
import typing_extensions as te
|
||||
|
||||
TELEMETRY_URL = "https://telemetry.composio.dev/v1"
|
||||
METRIC_ENDPOINT = f"{TELEMETRY_URL}/metrics/invocations"
|
||||
ERROR_ENDPOINT = f"{TELEMETRY_URL}/errors"
|
||||
|
||||
|
||||
class ErrorData(te.TypedDict):
|
||||
name: str
|
||||
"The name of the error"
|
||||
|
||||
code: te.NotRequired[str]
|
||||
"The code of the error"
|
||||
|
||||
errorId: te.NotRequired[str]
|
||||
"The error ID of the error"
|
||||
|
||||
message: te.NotRequired[str]
|
||||
"The message of the error"
|
||||
|
||||
stack: te.NotRequired[str]
|
||||
"The stack trace of the error"
|
||||
|
||||
|
||||
class SourceData(te.TypedDict):
|
||||
host: te.NotRequired[str]
|
||||
"The name of the source/host"
|
||||
|
||||
service: te.NotRequired[te.Literal["sdk", "apollo", "hermes", "thermos"]]
|
||||
"The service of the source"
|
||||
|
||||
language: te.NotRequired[te.Literal["python", "typescript", "go", "rust"]]
|
||||
"The language of the function that was invoked"
|
||||
|
||||
version: te.NotRequired[str]
|
||||
"The version of the source"
|
||||
|
||||
platform: te.NotRequired[str]
|
||||
"The platform of the source"
|
||||
|
||||
environment: te.NotRequired[
|
||||
te.Literal["development", "production", "ci", "staging", "test"]
|
||||
]
|
||||
"The environment of the source, eg: development, production, ci etc"
|
||||
|
||||
|
||||
class Metadata(te.TypedDict):
|
||||
projectId: te.NotRequired[str]
|
||||
"The project ID of the source"
|
||||
|
||||
provider: te.NotRequired[str]
|
||||
"The provider used in the source"
|
||||
|
||||
|
||||
class TelemetryData(te.TypedDict):
|
||||
functionName: str
|
||||
"The name of the function that was invoked"
|
||||
|
||||
durationMs: te.NotRequired[float]
|
||||
"The duration of the function invocation in milliseconds"
|
||||
|
||||
timestamp: te.NotRequired[float]
|
||||
"The timestamp of the function invocation in epoch seconds"
|
||||
|
||||
props: te.NotRequired[t.Dict]
|
||||
"The properties of the function invocation"
|
||||
|
||||
source: te.NotRequired[SourceData]
|
||||
"""Source of the metric"""
|
||||
|
||||
metadata: te.NotRequired[Metadata]
|
||||
"""Runtime metadata"""
|
||||
|
||||
error: te.NotRequired[ErrorData]
|
||||
"""Error data."""
|
||||
|
||||
|
||||
EventType: t.TypeAlias = t.Literal["metric", "error"]
|
||||
Event = t.Tuple[EventType, TelemetryData]
|
||||
EventQueue: t.TypeAlias = q.Queue[Event]
|
||||
|
||||
_queue: t.Optional[EventQueue] = None
|
||||
_event: t.Optional[tr.Event] = None
|
||||
_thread: t.Optional[tr.Thread] = None
|
||||
|
||||
|
||||
def _setup():
|
||||
global _queue, _event, _thread
|
||||
if _queue is None:
|
||||
_queue = q.Queue[Event]()
|
||||
|
||||
if _event is None:
|
||||
_event = tr.Event()
|
||||
|
||||
if _thread is None:
|
||||
_thread = tr.Thread(
|
||||
target=_thread_loop,
|
||||
kwargs={
|
||||
"queue": _queue,
|
||||
"event": _event,
|
||||
},
|
||||
daemon=True,
|
||||
)
|
||||
_thread.start()
|
||||
atexit.register(
|
||||
functools.partial(
|
||||
_teardown,
|
||||
queue=_queue,
|
||||
event=_event,
|
||||
thread=_thread,
|
||||
)
|
||||
)
|
||||
|
||||
return _queue, _event, _thread
|
||||
|
||||
|
||||
def _teardown(queue: EventQueue, event: tr.Event, thread: tr.Thread):
|
||||
# Wait max 2 seconds for queue to empty
|
||||
deadline = time.time() + 2.0
|
||||
while queue.qsize() and time.time() < deadline:
|
||||
time.sleep(0.1)
|
||||
|
||||
event.set()
|
||||
# Join with timeout to prevent infinite waiting
|
||||
thread.join(timeout=3.0)
|
||||
|
||||
|
||||
def _push(event: Event):
|
||||
try:
|
||||
_ = (
|
||||
httpx.post(
|
||||
url=METRIC_ENDPOINT,
|
||||
json=[event[1]],
|
||||
timeout=2.0, # 2 second timeout to prevent hanging
|
||||
)
|
||||
if event[0] == "metric"
|
||||
else httpx.post(
|
||||
url=ERROR_ENDPOINT,
|
||||
json=event[1],
|
||||
timeout=2.0, # 2 second timeout to prevent hanging
|
||||
)
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.HTTPError, Exception):
|
||||
# Silently fail - telemetry shouldn't break the application
|
||||
pass
|
||||
|
||||
|
||||
def _thread_loop(queue: EventQueue, event: tr.Event):
|
||||
while not event.is_set():
|
||||
try:
|
||||
_push(queue.get(timeout=0.1))
|
||||
except q.Empty:
|
||||
continue
|
||||
|
||||
|
||||
def push_event(event: Event):
|
||||
q, _, _ = _setup()
|
||||
q.put(event)
|
||||
|
||||
|
||||
def create_event(type: EventType, **payload: te.Unpack[TelemetryData]) -> Event:
|
||||
return type, payload
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
import typing_extensions as te
|
||||
|
||||
from composio.client.types import (
|
||||
auth_config_create_params,
|
||||
auth_config_create_response,
|
||||
auth_config_list_params,
|
||||
auth_config_list_response,
|
||||
auth_config_retrieve_response,
|
||||
auth_config_update_params,
|
||||
)
|
||||
from composio.core.models.base import Resource
|
||||
|
||||
|
||||
class AuthConfigs(Resource):
|
||||
"""
|
||||
Manage authentication configurations.
|
||||
"""
|
||||
|
||||
def list(
|
||||
self,
|
||||
**query: te.Unpack[auth_config_list_params.AuthConfigListParams],
|
||||
) -> auth_config_list_response.AuthConfigListResponse:
|
||||
"""
|
||||
Lists authentication configurations based on provided filter criteria.
|
||||
"""
|
||||
return self._client.auth_configs.list(**query)
|
||||
|
||||
@t.overload
|
||||
def create(
|
||||
self, toolkit: str, options: auth_config_create_params.AuthConfigUnionMember1
|
||||
) -> auth_config_create_response.AuthConfig: ...
|
||||
|
||||
@t.overload
|
||||
def create(
|
||||
self, toolkit: str, options: auth_config_create_params.AuthConfigUnionMember0
|
||||
) -> auth_config_create_response.AuthConfig: ...
|
||||
|
||||
def create(
|
||||
self, toolkit: str, options: auth_config_create_params.AuthConfig
|
||||
) -> auth_config_create_response.AuthConfig:
|
||||
"""
|
||||
Create a new auth config
|
||||
|
||||
:param toolkit: The toolkit to create the auth config for.
|
||||
:param options: The options to create the auth config with.
|
||||
:return: The created auth config.
|
||||
"""
|
||||
return self._client.auth_configs.create(
|
||||
toolkit={"slug": toolkit}, auth_config=options
|
||||
).auth_config
|
||||
|
||||
def get(
|
||||
self, nanoid: str
|
||||
) -> auth_config_retrieve_response.AuthConfigRetrieveResponse:
|
||||
"""
|
||||
Retrieves a specific authentication configuration by its ID
|
||||
|
||||
:param nanoid: The ID of the auth config to retrieve.
|
||||
:return: The retrieved auth config.
|
||||
"""
|
||||
return self._client.auth_configs.retrieve(nanoid)
|
||||
|
||||
@t.overload
|
||||
def update(
|
||||
self, nanoid: str, *, options: auth_config_update_params.Variant0
|
||||
) -> t.Dict: ...
|
||||
|
||||
@t.overload
|
||||
def update(
|
||||
self, nanoid: str, *, options: auth_config_update_params.Variant1
|
||||
) -> t.Dict: ...
|
||||
|
||||
# FIXME: what type is this response, in ts, it's AuthConfigUpdateResponse
|
||||
def update(
|
||||
self, nanoid: str, *, options: auth_config_update_params.AuthConfigUpdateParams
|
||||
) -> t.Dict:
|
||||
"""
|
||||
Updates an existing authentication configuration.
|
||||
|
||||
This method allows you to modify properties of an auth config such as credentials,
|
||||
scopes, or tool restrictions. The update type (custom or default) determines which
|
||||
fields can be updated.
|
||||
|
||||
:param nanoid: The ID of the auth config to update.
|
||||
:param options: The options to update the auth config with.
|
||||
:return: The updated auth config.
|
||||
"""
|
||||
return t.cast(
|
||||
t.Dict,
|
||||
self._client.auth_configs.update(
|
||||
nanoid=nanoid,
|
||||
type=options["type"], # type: ignore
|
||||
credentials=options.get("credentials", self._client.not_given),
|
||||
is_enabled_for_tool_router=options.get(
|
||||
"is_enabled_for_tool_router", self._client.not_given
|
||||
),
|
||||
tool_access_config=options.get(
|
||||
"tool_access_config", self._client.not_given
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def delete(self, nanoid: str) -> t.Dict:
|
||||
"""
|
||||
Deletes an existing authentication configuration.
|
||||
|
||||
:param nanoid: The ID of the auth config to delete.
|
||||
:return: The deleted auth config.
|
||||
"""
|
||||
return t.cast(t.Dict, self._client.auth_configs.delete(nanoid))
|
||||
|
||||
def __update_status(
|
||||
self,
|
||||
nanoid: str,
|
||||
status: t.Literal["ENABLED", "DISABLED"],
|
||||
) -> t.Dict:
|
||||
return t.cast(
|
||||
t.Dict,
|
||||
self._client.auth_configs.update_status(
|
||||
status,
|
||||
nanoid=nanoid,
|
||||
),
|
||||
)
|
||||
|
||||
def enable(self, nanoid: str) -> t.Dict:
|
||||
"""
|
||||
Enables an existing authentication configuration.
|
||||
|
||||
:param nanoid: The ID of the auth config to enable.
|
||||
:return: The enabled auth config.
|
||||
"""
|
||||
return self.__update_status(nanoid, "ENABLED")
|
||||
|
||||
def disable(self, nanoid: str) -> t.Dict:
|
||||
"""
|
||||
Disables an existing authentication configuration.
|
||||
|
||||
:param nanoid: The ID of the auth config to disable.
|
||||
:return: The disabled auth config.
|
||||
"""
|
||||
return self.__update_status(nanoid, "DISABLED")
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Base resource class for representing resources in the composio client.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import functools
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
import typing as t
|
||||
|
||||
from composio.__version__ import __version__
|
||||
from composio.client import HttpClient
|
||||
from composio.utils.logging import WithLogger
|
||||
|
||||
from ._telemetry import Event, create_event, push_event
|
||||
|
||||
PayloadT = t.TypeVar("PayloadT", bound=dict)
|
||||
|
||||
allow_tracking = contextvars.ContextVar[bool]("allow_tracking", default=True)
|
||||
_environment = os.getenv("ENVIRONMENT", "development")
|
||||
|
||||
|
||||
def trace_method(method: t.Callable, name: str) -> t.Callable:
|
||||
"""Wrap a method to log the call."""
|
||||
|
||||
# Check if the method is a class method
|
||||
if getattr(method, "__self__", None) is not None:
|
||||
return method
|
||||
|
||||
@functools.wraps(method)
|
||||
def trace_wrapper(self, *args: t.Any, **kwargs: t.Any) -> t.Any:
|
||||
if not allow_tracking.get():
|
||||
return method(self, *args, **kwargs)
|
||||
|
||||
event: t.Optional[Event] = None
|
||||
start_time = time.time()
|
||||
event = create_event(
|
||||
type="metric",
|
||||
functionName=name,
|
||||
timestamp=time.time(),
|
||||
props={},
|
||||
source={
|
||||
"environment": _environment, # type: ignore
|
||||
"language": "python",
|
||||
"service": "sdk",
|
||||
"version": __version__,
|
||||
},
|
||||
metadata={
|
||||
"provider": self._client.provider,
|
||||
},
|
||||
)
|
||||
try:
|
||||
return method(self, *args, **kwargs)
|
||||
except Exception as e:
|
||||
_, payload = event
|
||||
payload["error"] = {
|
||||
"name": e.__class__.__name__,
|
||||
"message": str(e),
|
||||
"stack": traceback.format_exc(),
|
||||
}
|
||||
event = ("error", payload)
|
||||
raise e
|
||||
finally:
|
||||
if event is not None:
|
||||
event[1]["durationMs"] = (time.time() - start_time) * 1000
|
||||
push_event(event=event)
|
||||
|
||||
trace_wrapper.__name__ = method.__name__
|
||||
return trace_wrapper
|
||||
|
||||
|
||||
class ResourceMeta(type):
|
||||
"""Meta class for resource classes."""
|
||||
|
||||
def __init__(cls, name, bases, attrs):
|
||||
for attr in attrs:
|
||||
if attr.startswith("_") or not callable(getattr(cls, attr)):
|
||||
continue
|
||||
setattr(cls, attr, trace_method(getattr(cls, attr), f"{name}.{attr}"))
|
||||
|
||||
|
||||
class Resource(WithLogger, metaclass=ResourceMeta):
|
||||
"""Base resource class for composio client."""
|
||||
|
||||
def sanitize_payload(self, payload: PayloadT) -> PayloadT:
|
||||
return {k: v for k, v in payload.items()} # type: ignore
|
||||
|
||||
def __init__(self, client: HttpClient):
|
||||
super().__init__()
|
||||
self._client = client
|
||||
@@ -0,0 +1,746 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
import typing as t
|
||||
import warnings
|
||||
|
||||
import typing_extensions as te
|
||||
from composio_client import BadRequestError, omit
|
||||
|
||||
from composio import exceptions
|
||||
from composio.client import HttpClient
|
||||
from composio.client.types import (
|
||||
connected_account_create_params,
|
||||
connected_account_patch_params,
|
||||
connected_account_patch_response,
|
||||
connected_account_retrieve_response,
|
||||
connected_account_update_status_response,
|
||||
link_create_params,
|
||||
)
|
||||
|
||||
from .base import Resource
|
||||
from .experimental import ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mirrors TS `ConnectionRequest.ts:terminalErrorStates`. INACTIVE is excluded
|
||||
# on purpose — it can recover to ACTIVE.
|
||||
_TERMINAL_CONNECTION_STATES: t.FrozenSet[str] = frozenset(
|
||||
{"FAILED", "EXPIRED", "REVOKED"}
|
||||
)
|
||||
|
||||
# One-time-per-process guard so long-running services don't spam the deprecation
|
||||
# warning on every initiate() call.
|
||||
_legacy_initiate_warning_emitted = False
|
||||
|
||||
|
||||
class ConnectionRequest(Resource):
|
||||
"""
|
||||
A connection request.
|
||||
|
||||
This class is used to manage connection requests.
|
||||
"""
|
||||
|
||||
DEFAULT_WAIT_TIMEOUT = 60.0 # Seconds
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
status: str,
|
||||
redirect_url: t.Optional[str],
|
||||
client: HttpClient,
|
||||
):
|
||||
"""
|
||||
Initialize the connection request.
|
||||
|
||||
:param id: The ID of the connection request.
|
||||
:param status: The status of the connection request.
|
||||
:param redirect_url: The redirect URL of the connection request.
|
||||
:param client: The client to use for the connection request.
|
||||
"""
|
||||
super().__init__(client)
|
||||
self.id = id
|
||||
self.status = status
|
||||
self.redirect_url = redirect_url
|
||||
|
||||
def wait_for_connection(
|
||||
self,
|
||||
timeout: t.Optional[float] = None,
|
||||
) -> connected_account_retrieve_response.ConnectedAccountRetrieveResponse:
|
||||
"""
|
||||
Wait for the connection to be established.
|
||||
|
||||
:param timeout: The timeout to wait for the connection to be established.
|
||||
:return: Connected account object.
|
||||
"""
|
||||
timeout = self.DEFAULT_WAIT_TIMEOUT if timeout is None else timeout
|
||||
deadline = time.time() + timeout
|
||||
while deadline > time.time():
|
||||
connection = self._client.connected_accounts.retrieve(nanoid=self.id)
|
||||
self.status = connection.status
|
||||
if self.status == "ACTIVE":
|
||||
return connection
|
||||
if self.status in _TERMINAL_CONNECTION_STATES:
|
||||
raise exceptions.SDKError(
|
||||
message=(
|
||||
f"Connection {self.id} entered terminal state "
|
||||
f"{self.status!r} before becoming active"
|
||||
),
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
raise exceptions.ComposioSDKTimeoutError(
|
||||
message=f"Timeout while waiting for connection {self.id} to be active",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_id(cls, id: str, client: HttpClient) -> te.Self:
|
||||
return cls(
|
||||
id=id,
|
||||
status=client.connected_accounts.retrieve(nanoid=id).status,
|
||||
redirect_url=None,
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
class AuthScheme:
|
||||
"""
|
||||
Collection of auth scheme helpers.
|
||||
"""
|
||||
|
||||
def oauth1(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember0Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using OAuth 1.0.
|
||||
|
||||
When both ``oauth_token`` and ``oauth_token_secret`` are provided,
|
||||
status defaults to ACTIVE (token import). When either is omitted,
|
||||
status defaults to INITIALIZING (redirect-based OAuth flow).
|
||||
Pass an explicit ``status`` in options to override.
|
||||
"""
|
||||
has_tokens = bool(
|
||||
options.get("oauth_token") # type: ignore[union-attr]
|
||||
) and bool(
|
||||
options.get("oauth_token_secret") # type: ignore[union-attr]
|
||||
)
|
||||
status = "ACTIVE" if has_tokens else "INITIALIZING"
|
||||
return {
|
||||
"auth_scheme": "OAUTH1",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember0Val,
|
||||
{
|
||||
"status": status,
|
||||
**options,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
def oauth2(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember1Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using OAuth 2.0.
|
||||
|
||||
When ``access_token`` is provided, status defaults to ACTIVE
|
||||
(token import). When omitted, status defaults to INITIALIZING
|
||||
(redirect-based OAuth flow). Pass an explicit ``status`` in
|
||||
options to override.
|
||||
"""
|
||||
has_token = bool(options.get("access_token")) # type: ignore[union-attr]
|
||||
status = "ACTIVE" if has_token else "INITIALIZING"
|
||||
return {
|
||||
"auth_scheme": "OAUTH2",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember1Val,
|
||||
{
|
||||
"status": status,
|
||||
**options,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
def composio_link(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember2Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using Composio Link.
|
||||
"""
|
||||
return t.cast(
|
||||
connected_account_create_params.ConnectionState,
|
||||
{
|
||||
"auth_scheme": "COMPOSIO_LINK",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember2Val,
|
||||
{
|
||||
"status": "INITIALIZING",
|
||||
**options,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def api_key(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember3Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using an API key.
|
||||
"""
|
||||
return t.cast(
|
||||
connected_account_create_params.ConnectionState,
|
||||
{
|
||||
"auth_scheme": "API_KEY",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember3Val,
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
**options,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def basic(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember4Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using basic auth.
|
||||
"""
|
||||
return t.cast(
|
||||
connected_account_create_params.ConnectionState,
|
||||
{
|
||||
"auth_scheme": "BASIC",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember4Val,
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
**options,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def bearer_token(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember5Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using a bearer token.
|
||||
"""
|
||||
return t.cast(
|
||||
connected_account_create_params.ConnectionState,
|
||||
{
|
||||
"auth_scheme": "BEARER_TOKEN",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember5Val,
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
**options,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def google_service_account(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember6Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using a Google service account.
|
||||
"""
|
||||
return t.cast(
|
||||
connected_account_create_params.ConnectionState,
|
||||
{
|
||||
"auth_scheme": "GOOGLE_SERVICE_ACCOUNT",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember6Val,
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
**options,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def no_auth(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember7Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using no auth.
|
||||
"""
|
||||
return {
|
||||
"auth_scheme": "NO_AUTH",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember7Val,
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
**options,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
def calcom_auth(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember8Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using Cal.com auth.
|
||||
"""
|
||||
return {
|
||||
"auth_scheme": "CALCOM_AUTH",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember8Val,
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
**options,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
def billcom_auth(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember9Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using Bill.com auth.
|
||||
"""
|
||||
return t.cast(
|
||||
connected_account_create_params.ConnectionState,
|
||||
{
|
||||
"auth_scheme": "BILLCOM_AUTH",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember9Val,
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
**options,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def basic_with_jwt(
|
||||
self, options: connected_account_create_params.ConnectionStateUnionMember10Val
|
||||
) -> connected_account_create_params.ConnectionState:
|
||||
"""
|
||||
Create a new connected account using basic auth with JWT.
|
||||
"""
|
||||
return t.cast(
|
||||
connected_account_create_params.ConnectionState,
|
||||
{
|
||||
"auth_scheme": "BASIC_WITH_JWT",
|
||||
"val": t.cast(
|
||||
connected_account_create_params.ConnectionStateUnionMember10Val,
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
**options,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ConnectedAccounts:
|
||||
"""
|
||||
Manage connected accounts.
|
||||
|
||||
This class is used to manage connected accounts in the Composio SDK.
|
||||
These are used to authenticate with third-party services.
|
||||
"""
|
||||
|
||||
enable: t.Callable[
|
||||
[str],
|
||||
connected_account_update_status_response.ConnectedAccountUpdateStatusResponse,
|
||||
]
|
||||
"""Enable a connected account."""
|
||||
|
||||
disable: t.Callable[
|
||||
[str],
|
||||
connected_account_update_status_response.ConnectedAccountUpdateStatusResponse,
|
||||
]
|
||||
"""Disable a connected account."""
|
||||
|
||||
def __init__(self, client: HttpClient):
|
||||
"""
|
||||
Initialize the connected accounts resource.
|
||||
|
||||
:param client: The client to use for the connected accounts resource.
|
||||
"""
|
||||
self._client = client
|
||||
self.get = self._client.connected_accounts.retrieve
|
||||
self.list = self._client.connected_accounts.list
|
||||
self.delete = self._client.connected_accounts.delete
|
||||
self.update_status = self._client.connected_accounts.update_status
|
||||
self.refresh = self._client.connected_accounts.refresh
|
||||
self.enable = functools.partial(
|
||||
self._client.connected_accounts.update_status,
|
||||
enabled=True,
|
||||
)
|
||||
self.disable = functools.partial(
|
||||
self._client.connected_accounts.update_status,
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
def update(
|
||||
self,
|
||||
nanoid: str,
|
||||
*,
|
||||
alias: t.Optional[str] = None,
|
||||
connection: t.Optional[connected_account_patch_params.Connection] = None,
|
||||
) -> connected_account_patch_response.ConnectedAccountPatchResponse:
|
||||
"""
|
||||
Update a connected account's alias and/or credentials.
|
||||
|
||||
:param nanoid: The connected account ID (ca_xxx).
|
||||
:param alias: Human-readable alias. Pass an empty string to clear.
|
||||
Must be unique per entity and toolkit within the project.
|
||||
:param connection: Credential update with authScheme and val fields.
|
||||
:return: Response with ``id``, ``status``, and ``success``.
|
||||
|
||||
Example:
|
||||
# Set an alias
|
||||
composio.connected_accounts.update('ca_abc123', alias='work-gmail')
|
||||
|
||||
# Clear an alias
|
||||
composio.connected_accounts.update('ca_abc123', alias='')
|
||||
"""
|
||||
return self._client.connected_accounts.patch(
|
||||
nanoid,
|
||||
alias=alias if alias is not None else omit,
|
||||
connection=connection if connection is not None else omit,
|
||||
)
|
||||
|
||||
def update_acl(
|
||||
self,
|
||||
nanoid: str,
|
||||
*,
|
||||
allow_all_users: t.Optional[bool] = None,
|
||||
allowed_user_ids: t.Optional[t.List[str]] = None,
|
||||
not_allowed_user_ids: t.Optional[t.List[str]] = None,
|
||||
) -> connected_account_patch_response.ConnectedAccountPatchResponse:
|
||||
"""
|
||||
Update the per-user ACL on a SHARED connected account. Experimental —
|
||||
shape may change in future releases.
|
||||
|
||||
Only valid on SHARED connections; raises
|
||||
``ComposioAclOnlyForSharedError`` on a PRIVATE connection. Omit a
|
||||
parameter to leave it unchanged; pass an empty list to clear an
|
||||
allow/deny list. At least one parameter must be provided.
|
||||
|
||||
:param nanoid: The connected account ID (``ca_xxx``).
|
||||
:param allow_all_users: When True, any ``user_id`` may use this
|
||||
SHARED connection (subject to the deny list).
|
||||
:param allowed_user_ids: Explicit list of allowed ``user_id`` strings.
|
||||
Pass ``[]`` to clear.
|
||||
:param not_allowed_user_ids: Explicit deny list (wins over allow on
|
||||
conflict). Pass ``[]`` to clear — note that clearing the deny
|
||||
list silently re-grants access to previously-blocked users.
|
||||
:return: Response with ``id``, ``status``, and ``success``.
|
||||
|
||||
Example:
|
||||
composio.connected_accounts.update_acl(
|
||||
'ca_abc',
|
||||
allow_all_users=True,
|
||||
not_allowed_user_ids=['user_bob'],
|
||||
)
|
||||
"""
|
||||
if (
|
||||
allow_all_users is None
|
||||
and allowed_user_ids is None
|
||||
and not_allowed_user_ids is None
|
||||
):
|
||||
raise exceptions.ValidationError(
|
||||
"update_acl requires at least one of allow_all_users, "
|
||||
"allowed_user_ids, or not_allowed_user_ids"
|
||||
)
|
||||
|
||||
acl: t.Dict[str, t.Any] = {}
|
||||
if allow_all_users is not None:
|
||||
acl["allow_all_users"] = allow_all_users
|
||||
if allowed_user_ids is not None:
|
||||
acl["allowed_user_ids"] = allowed_user_ids
|
||||
if not_allowed_user_ids is not None:
|
||||
acl["not_allowed_user_ids"] = not_allowed_user_ids
|
||||
|
||||
try:
|
||||
return self._client.connected_accounts.patch(
|
||||
nanoid,
|
||||
experimental={
|
||||
"acl_config_for_shared": t.cast(
|
||||
connected_account_patch_params.ExperimentalACLConfigForShared,
|
||||
acl,
|
||||
),
|
||||
},
|
||||
)
|
||||
except BadRequestError as error:
|
||||
message = str(error)
|
||||
if ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT in message:
|
||||
raise exceptions.ComposioAclOnlyForSharedError(message) from error
|
||||
raise
|
||||
|
||||
def initiate(
|
||||
self,
|
||||
user_id: str,
|
||||
auth_config_id: str,
|
||||
*,
|
||||
callback_url: t.Optional[str] = None,
|
||||
allow_multiple: bool = False,
|
||||
config: t.Optional[connected_account_create_params.ConnectionState] = None,
|
||||
alias: t.Optional[str] = None,
|
||||
) -> ConnectionRequest:
|
||||
"""
|
||||
Compound function to create a new connected account. This function creates
|
||||
a new connected account and returns a connection request.
|
||||
|
||||
Users can then wait for the connection to be established using the
|
||||
``wait_for_connection`` method.
|
||||
|
||||
.. deprecated::
|
||||
For Composio-managed (default) auth configs on redirectable OAuth
|
||||
schemes (OAuth1, OAuth2, DCR_OAUTH), the legacy endpoint this
|
||||
method wraps is being retired: **2026-05-08** for new
|
||||
organizations and **2026-07-03** for all remaining organizations.
|
||||
After your org's cutover, this method will raise
|
||||
:class:`composio.exceptions.ComposioLegacyConnectedAccountsEndpointRetiredError`
|
||||
for that specific combination.
|
||||
|
||||
Use :meth:`ConnectedAccounts.link` for Composio-managed OAuth — it
|
||||
works for every redirectable scheme regardless of whether the
|
||||
auth config is Composio-managed or custom, and the return shape
|
||||
is the same.
|
||||
|
||||
Custom auth configs (your own OAuth app) and non-OAuth schemes
|
||||
(API key, bearer token, basic auth) are unaffected and continue
|
||||
to work on ``initiate()``. See
|
||||
https://docs.composio.dev/docs/changelog/2026/04/24
|
||||
|
||||
:param user_id: The user ID to create the connected account for.
|
||||
:param auth_config_id: The auth config ID to create the connected account for.
|
||||
:param callback_url: Callback URL to use for OAuth apps.
|
||||
:param config: The configuration to create the connected account with.
|
||||
:param allow_multiple: Whether to allow multiple connected accounts for the same user and auth config.
|
||||
:param alias: Optional human-readable alias for the account. Must be unique per userId and toolkit within the project.
|
||||
:return: The connection request.
|
||||
"""
|
||||
# Check if there are multiple connected accounts for the authConfig of the user
|
||||
connected_accounts = self.list(
|
||||
user_ids=[user_id], auth_config_ids=[auth_config_id], statuses=["ACTIVE"]
|
||||
)
|
||||
if connected_accounts.items and not allow_multiple:
|
||||
raise exceptions.ComposioMultipleConnectedAccountsError(
|
||||
f"Multiple connected accounts found for user {user_id} in auth config {auth_config_id}. "
|
||||
"Please use the allow_multiple option to allow multiple connected accounts."
|
||||
)
|
||||
elif connected_accounts.items:
|
||||
logger.warning(
|
||||
"[Warn:AllowMultiple] Multiple connected accounts found for user %s in auth config %s",
|
||||
user_id,
|
||||
auth_config_id,
|
||||
)
|
||||
|
||||
connection: dict[str, t.Any] = {"user_id": user_id}
|
||||
if callback_url is not None:
|
||||
connection["callback_url"] = callback_url
|
||||
if config is not None:
|
||||
connection["state"] = config
|
||||
if alias is not None:
|
||||
connection["alias"] = alias
|
||||
|
||||
# Use `with_raw_response.create` so we can read the SEC-339
|
||||
# `Deprecation` header (RFC 9745) the apollo retiring branch sets —
|
||||
# that header is emitted only when the auth config is Composio-managed
|
||||
# AND on a redirectable OAuth scheme, so it's the canonical signal
|
||||
# that this caller needs to migrate. Custom auth configs and non-OAuth
|
||||
# schemes never see the header, eliminating the false-positive warning
|
||||
# that an auth_scheme-only check produced for link()-unaffected callers.
|
||||
deprecation_header: t.Optional[str] = None
|
||||
try:
|
||||
ca_client = self._client.connected_accounts
|
||||
raw_create = getattr(
|
||||
getattr(ca_client, "with_raw_response", None), "create", None
|
||||
)
|
||||
if callable(raw_create):
|
||||
raw = raw_create(
|
||||
auth_config={"id": auth_config_id},
|
||||
connection=t.cast(
|
||||
connected_account_create_params.Connection, connection
|
||||
),
|
||||
)
|
||||
response = raw.parse() if callable(getattr(raw, "parse", None)) else raw
|
||||
headers = getattr(raw, "headers", None)
|
||||
if headers is not None and hasattr(headers, "get"):
|
||||
value = headers.get("Deprecation") or headers.get("deprecation")
|
||||
if isinstance(value, str):
|
||||
deprecation_header = value
|
||||
else:
|
||||
# Test mocks may not stub `with_raw_response`. Fall back to
|
||||
# the parsed-only path; the deprecation gate stays off (no
|
||||
# header to read).
|
||||
response = ca_client.create(
|
||||
auth_config={"id": auth_config_id},
|
||||
connection=t.cast(
|
||||
connected_account_create_params.Connection, connection
|
||||
),
|
||||
)
|
||||
except BadRequestError as error:
|
||||
# When the server has flipped this org to the retired path, the
|
||||
# legacy endpoint returns 400 with a stable migration message.
|
||||
# Surface it as a typed error so callers get an actionable hint
|
||||
# instead of a generic BadRequestError.
|
||||
message = str(error)
|
||||
if (
|
||||
"no longer supported" in message
|
||||
and "/api/v3/connected_accounts/link" in message
|
||||
):
|
||||
raise exceptions.ComposioLegacyConnectedAccountsEndpointRetiredError(
|
||||
message
|
||||
) from error
|
||||
raise
|
||||
|
||||
# Warn once per process when apollo flags this response as on the
|
||||
# retiring path. Header presence is a 1:1 signal — custom auth
|
||||
# configs and non-OAuth schemes get a clean response and stay silent,
|
||||
# fixing the false-positive that auth_scheme-based detection
|
||||
# produced.
|
||||
global _legacy_initiate_warning_emitted
|
||||
if not _legacy_initiate_warning_emitted and deprecation_header:
|
||||
_legacy_initiate_warning_emitted = True
|
||||
warnings.warn(
|
||||
"composio.connected_accounts.initiate() will stop working "
|
||||
"for this auth config on or before 2026-07-03 (see Sunset "
|
||||
"header on the response). Switch to "
|
||||
"composio.connected_accounts.link() — same return shape, "
|
||||
"same allow_multiple semantics. "
|
||||
"https://docs.composio.dev/docs/changelog/2026/04/24",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return ConnectionRequest(
|
||||
id=response.id,
|
||||
status=response.connection_data.val.status,
|
||||
redirect_url=getattr(response.connection_data.val, "redirect_url", None),
|
||||
client=self._client,
|
||||
)
|
||||
|
||||
def link(
|
||||
self,
|
||||
user_id: str,
|
||||
auth_config_id: str,
|
||||
*,
|
||||
callback_url: t.Optional[str] = None,
|
||||
alias: t.Optional[str] = None,
|
||||
allow_multiple: bool = False,
|
||||
experimental: t.Optional[link_create_params.Experimental] = None,
|
||||
) -> ConnectionRequest:
|
||||
"""
|
||||
Create a Composio Connect Link for a user to connect their account to a given auth config.
|
||||
|
||||
This method will return an external link which you can use for the user to connect their account.
|
||||
|
||||
:param user_id: The external user ID to create the connected account for.
|
||||
:param auth_config_id: The auth config ID to create the connected account for.
|
||||
:param callback_url: The URL to redirect the user to post connecting their account.
|
||||
:param alias: Optional human-readable alias for the connection. Must be unique
|
||||
per userId and toolkit within the project.
|
||||
:param allow_multiple: Whether to allow multiple connected accounts for the same
|
||||
user and auth config. When False (default), raises
|
||||
``ComposioMultipleConnectedAccountsError`` if the user already has an
|
||||
``ACTIVE`` connection on this auth config. Pair with ``alias`` and a
|
||||
session-level ``multi_account`` config to disambiguate at execution time.
|
||||
:param experimental: Experimental options for this connection. Pass an
|
||||
``Experimental`` dict with ``account_type`` and/or
|
||||
``acl_config_for_shared`` to create a SHARED connection with a
|
||||
per-user ACL. Experimental — shape may change in future releases.
|
||||
:return: Connection request object.
|
||||
|
||||
Example:
|
||||
# Create a connection request and redirect the user to the redirect url
|
||||
connection_request = composio.connected_accounts.link('user_123', 'auth_config_123')
|
||||
redirect_url = connection_request.redirect_url
|
||||
print(f"Visit: {redirect_url} to authenticate your account")
|
||||
|
||||
# Wait for the connection to be established
|
||||
connected_account = connection_request.wait_for_connection()
|
||||
|
||||
Example with callback URL:
|
||||
# Create a connection request with callback URL
|
||||
connection_request = composio.connected_accounts.link(
|
||||
'user_123',
|
||||
'auth_config_123',
|
||||
callback_url='https://your-app.com/callback'
|
||||
)
|
||||
redirect_url = connection_request.redirect_url
|
||||
print(f"Visit: {redirect_url} to authenticate your account")
|
||||
|
||||
# Wait for the connection to be established
|
||||
connected_account = composio.connected_accounts.wait_for_connection(connection_request.id)
|
||||
|
||||
Example creating a SHARED connection with an ACL (experimental):
|
||||
connection_request = composio.connected_accounts.link(
|
||||
'user_creator',
|
||||
'auth_config_123',
|
||||
experimental={
|
||||
'account_type': 'SHARED',
|
||||
'acl_config_for_shared': {
|
||||
'allow_all_users': True,
|
||||
'not_allowed_user_ids': ['user_bob'],
|
||||
},
|
||||
},
|
||||
)
|
||||
"""
|
||||
# Mirror ``initiate()``: guard against silently creating extra
|
||||
# connections on the same auth config.
|
||||
connected_accounts = self.list(
|
||||
user_ids=[user_id], auth_config_ids=[auth_config_id], statuses=["ACTIVE"]
|
||||
)
|
||||
if connected_accounts.items and not allow_multiple:
|
||||
raise exceptions.ComposioMultipleConnectedAccountsError(
|
||||
f"Multiple connected accounts found for user {user_id} in auth config {auth_config_id}. "
|
||||
"Please use the allow_multiple option to allow multiple connected accounts."
|
||||
)
|
||||
elif connected_accounts.items:
|
||||
logger.warning(
|
||||
"[Warn:AllowMultiple] Multiple connected accounts found for user %s in auth config %s",
|
||||
user_id,
|
||||
auth_config_id,
|
||||
)
|
||||
|
||||
try:
|
||||
response = self._client.link.create(
|
||||
auth_config_id=auth_config_id,
|
||||
user_id=user_id,
|
||||
callback_url=callback_url if callback_url is not None else omit,
|
||||
alias=alias if alias is not None else omit,
|
||||
experimental=experimental if experimental is not None else omit,
|
||||
)
|
||||
except BadRequestError as error:
|
||||
# The server rejects ACL on PRIVATE connections — surface that
|
||||
# as a typed error so callers can ``except`` instead of grepping
|
||||
# messages.
|
||||
message = str(error)
|
||||
if ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT in message:
|
||||
raise exceptions.ComposioAclOnlyForSharedError(message) from error
|
||||
raise
|
||||
|
||||
return ConnectionRequest(
|
||||
id=response.connected_account_id,
|
||||
status="INITIATED",
|
||||
redirect_url=getattr(response, "redirect_url", None),
|
||||
client=self._client,
|
||||
)
|
||||
|
||||
def wait_for_connection(
|
||||
self,
|
||||
id: str,
|
||||
timeout: t.Optional[float] = None,
|
||||
) -> connected_account_retrieve_response.ConnectedAccountRetrieveResponse:
|
||||
"""
|
||||
Wait for connected account with given ID to be active
|
||||
"""
|
||||
return ConnectionRequest.from_id(
|
||||
id=id,
|
||||
client=self._client,
|
||||
).wait_for_connection(
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
auth_scheme = AuthScheme()
|
||||
@@ -0,0 +1,788 @@
|
||||
"""Custom tools and toolkits for tool router sessions.
|
||||
|
||||
Decorator API for defining custom tools that run in-process alongside
|
||||
remote Composio tools. Accessed via ``composio.experimental``.
|
||||
|
||||
Usage::
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
class GrepInput(BaseModel):
|
||||
pattern: str = Field(description="Pattern to search for")
|
||||
|
||||
@composio.experimental.tool()
|
||||
def grep(input: GrepInput, ctx):
|
||||
\"\"\"Search for a pattern in local files.\"\"\"
|
||||
return {"matches": []}
|
||||
|
||||
dev_tools = composio.experimental.Toolkit(
|
||||
slug="DEV_TOOLS",
|
||||
name="Dev Tools",
|
||||
description="Local dev utilities",
|
||||
)
|
||||
|
||||
@dev_tools.tool()
|
||||
def search_code(input: GrepInput, ctx):
|
||||
\"\"\"Search developer resources.\"\"\"
|
||||
return {"results": []}
|
||||
|
||||
session = composio.create(
|
||||
user_id="default",
|
||||
experimental={
|
||||
"custom_tools": [grep],
|
||||
"custom_toolkits": [dev_tools],
|
||||
},
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import typing as t
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from composio.exceptions import ValidationError
|
||||
|
||||
from .custom_tool_types import (
|
||||
LOCAL_TOOL_PREFIX,
|
||||
MAX_SLUG_LENGTH,
|
||||
SLUG_REGEX,
|
||||
CustomTool,
|
||||
CustomToolExecuteFn,
|
||||
CustomToolkitWireDefinition,
|
||||
CustomToolsMap,
|
||||
CustomToolsMapEntry,
|
||||
CustomToolWireDefinition,
|
||||
)
|
||||
from .tool_router_constants import PRELOAD_TOOLS_ALL
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from composio_client.types.tool_router.session_attach_response import (
|
||||
Experimental as SessionAttachResponseExperimental,
|
||||
)
|
||||
from composio_client.types.tool_router.session_create_response import (
|
||||
Experimental as SessionCreateResponseExperimental,
|
||||
)
|
||||
from composio_client.types.tool_router.session_retrieve_response import (
|
||||
Experimental as SessionRetrieveResponseExperimental,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Slug validation helpers
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_slug(slug: str, context: str) -> str:
|
||||
"""Validate a custom tool or toolkit slug."""
|
||||
if not slug:
|
||||
raise ValidationError(f"{context}: slug is required")
|
||||
|
||||
if not SLUG_REGEX.match(slug):
|
||||
raise ValidationError(
|
||||
f"{context}: slug must only contain alphanumeric characters, "
|
||||
f"underscores, and hyphens"
|
||||
)
|
||||
|
||||
upper = slug.upper()
|
||||
if upper.startswith("LOCAL_"):
|
||||
raise ValidationError(
|
||||
f'{context}: slug must not start with "LOCAL_" — '
|
||||
f"this prefix is reserved for internal routing."
|
||||
)
|
||||
if upper.startswith("COMPOSIO_"):
|
||||
raise ValidationError(
|
||||
f'{context}: slug must not start with "COMPOSIO_" — '
|
||||
f"this prefix is reserved for Composio meta tools."
|
||||
)
|
||||
|
||||
return slug
|
||||
|
||||
|
||||
def _compute_final_slug_length(tool_slug: str, toolkit_slug: t.Optional[str]) -> int:
|
||||
"""Compute the final slug length: LOCAL_[TOOLKIT_]SLUG."""
|
||||
length = len(LOCAL_TOOL_PREFIX) + len(tool_slug)
|
||||
if toolkit_slug:
|
||||
length += len(toolkit_slug) + 1 # +1 for underscore separator
|
||||
return length
|
||||
|
||||
|
||||
def _validate_slug_length(
|
||||
tool_slug: str, toolkit_slug: t.Optional[str], context: str
|
||||
) -> None:
|
||||
"""Validate that the final slug won't exceed the max length."""
|
||||
final_length = _compute_final_slug_length(tool_slug, toolkit_slug)
|
||||
if final_length > MAX_SLUG_LENGTH:
|
||||
prefix = LOCAL_TOOL_PREFIX + (
|
||||
f"{toolkit_slug.upper()}_" if toolkit_slug else ""
|
||||
)
|
||||
available = MAX_SLUG_LENGTH - len(prefix)
|
||||
raise ValidationError(
|
||||
f'{context}: slug "{tool_slug}" is too long. '
|
||||
f'With prefix "{prefix}", the final slug would be {final_length} '
|
||||
f"characters (max {MAX_SLUG_LENGTH}). "
|
||||
f"Shorten the slug to at most {available} characters."
|
||||
)
|
||||
|
||||
|
||||
def _build_final_slug(tool_slug: str, toolkit_slug: t.Optional[str] = None) -> str:
|
||||
"""Build the final slug: LOCAL_[TOOLKIT_]SLUG."""
|
||||
upper = tool_slug.upper()
|
||||
if toolkit_slug:
|
||||
return f"{LOCAL_TOOL_PREFIX}{toolkit_slug.upper()}_{upper}"
|
||||
return f"{LOCAL_TOOL_PREFIX}{upper}"
|
||||
|
||||
|
||||
def _get_input_json_schema(model: t.Type[BaseModel]) -> t.Dict[str, t.Any]:
|
||||
"""Convert a Pydantic model class to a JSON Schema dict suitable for the backend."""
|
||||
full_schema = model.model_json_schema()
|
||||
schema: t.Dict[str, t.Any] = {"type": "object"}
|
||||
if "properties" in full_schema:
|
||||
schema["properties"] = full_schema["properties"]
|
||||
if "required" in full_schema:
|
||||
schema["required"] = full_schema["required"]
|
||||
if "$defs" in full_schema:
|
||||
schema["$defs"] = full_schema["$defs"]
|
||||
return schema
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Internal tool creation (used by decorator API)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _create_tool(
|
||||
slug: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
input_params: t.Type[BaseModel],
|
||||
execute: CustomToolExecuteFn,
|
||||
extends_toolkit: t.Optional[str] = None,
|
||||
output_params: t.Optional[t.Type[BaseModel]] = None,
|
||||
preload: t.Optional[bool] = None,
|
||||
) -> CustomTool:
|
||||
"""Internal: create and validate a CustomTool."""
|
||||
context = "experimental.tool"
|
||||
|
||||
_validate_slug(slug, context)
|
||||
|
||||
if not name:
|
||||
raise ValidationError(f"{context}: name is required")
|
||||
if not description:
|
||||
raise ValidationError(f"{context}: description is required")
|
||||
|
||||
if not isinstance(input_params, type) or not issubclass(input_params, BaseModel):
|
||||
raise ValidationError(
|
||||
f"{context}: input_params must be a Pydantic BaseModel subclass. "
|
||||
f"Tool input parameters are always an object with named properties."
|
||||
)
|
||||
|
||||
try:
|
||||
from pydantic import RootModel
|
||||
|
||||
if issubclass(input_params, RootModel):
|
||||
raise ValidationError(
|
||||
f"{context}: input_params must be a regular BaseModel with named fields, "
|
||||
f"not a RootModel. Tool input parameters are always an object with "
|
||||
f"named properties."
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not callable(execute):
|
||||
raise ValidationError(f"{context}: execute must be a callable")
|
||||
|
||||
if asyncio.iscoroutinefunction(execute):
|
||||
raise ValidationError(
|
||||
f"{context}: execute must be a synchronous function, not async. "
|
||||
f"The Composio Python SDK is synchronous — use a regular "
|
||||
f"'def fn(input, ctx)' instead of 'async def'."
|
||||
)
|
||||
|
||||
_validate_slug_length(slug, extends_toolkit, context)
|
||||
|
||||
input_schema = _get_input_json_schema(input_params)
|
||||
|
||||
output_schema: t.Optional[t.Dict[str, t.Any]] = None
|
||||
if output_params is not None:
|
||||
if not isinstance(output_params, type) or not issubclass(
|
||||
output_params, BaseModel
|
||||
):
|
||||
raise ValidationError(
|
||||
f"{context}: output_params must be a Pydantic BaseModel subclass"
|
||||
)
|
||||
output_schema = output_params.model_json_schema()
|
||||
|
||||
return CustomTool(
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
extends_toolkit=extends_toolkit,
|
||||
input_schema=input_schema,
|
||||
output_schema=output_schema,
|
||||
input_params=input_params,
|
||||
execute=execute,
|
||||
preload=preload,
|
||||
)
|
||||
|
||||
|
||||
def _get_caller_locals(depth: int = 2) -> t.Optional[t.Mapping[str, t.Any]]:
|
||||
"""Best-effort lookup of a caller frame's locals."""
|
||||
frame = inspect.currentframe()
|
||||
try:
|
||||
caller = frame
|
||||
for _ in range(depth):
|
||||
caller = caller.f_back if caller is not None else None
|
||||
return caller.f_locals if caller is not None else None
|
||||
finally:
|
||||
del frame
|
||||
|
||||
|
||||
def _resolve_function_annotations(
|
||||
fn: t.Callable[..., t.Any],
|
||||
*,
|
||||
localns: t.Optional[t.Mapping[str, t.Any]] = None,
|
||||
) -> t.Dict[str, t.Any]:
|
||||
"""Resolve annotations, including postponed string annotations when possible."""
|
||||
try:
|
||||
return t.get_type_hints(
|
||||
fn,
|
||||
globalns=getattr(fn, "__globals__", {}),
|
||||
localns=dict(localns) if localns is not None else None,
|
||||
include_extras=True,
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _infer_tool_from_function(
|
||||
fn: t.Callable[..., t.Any],
|
||||
*,
|
||||
slug: t.Optional[str] = None,
|
||||
name: t.Optional[str] = None,
|
||||
description: t.Optional[str] = None,
|
||||
extends_toolkit: t.Optional[str] = None,
|
||||
output_params: t.Optional[t.Type[BaseModel]] = None,
|
||||
preload: t.Optional[bool] = None,
|
||||
annotation_locals: t.Optional[t.Mapping[str, t.Any]] = None,
|
||||
) -> CustomTool:
|
||||
"""Create a CustomTool by inferring metadata from a decorated function.
|
||||
|
||||
- slug: from ``fn.__name__.upper()``
|
||||
- name: from ``fn.__name__`` humanized
|
||||
- description: from ``fn.__doc__``
|
||||
- input_params: from the first parameter with a BaseModel type annotation
|
||||
"""
|
||||
# Infer slug
|
||||
actual_slug = slug or fn.__name__.upper()
|
||||
actual_name = name or fn.__name__.replace("_", " ").title()
|
||||
actual_description = description or inspect.cleandoc(fn.__doc__ or "")
|
||||
|
||||
if not actual_description:
|
||||
raise ValidationError(
|
||||
f"experimental.tool: description is required. "
|
||||
f'Add a docstring to "{fn.__name__}" or pass description=...'
|
||||
)
|
||||
|
||||
# Validate and infer function signature.
|
||||
# Accepted shapes (consistent with TS CustomToolExecuteFn):
|
||||
# (input: BaseModel) — no session context needed
|
||||
# (input: BaseModel, ctx) — with session context
|
||||
# The first param MUST be annotated with a BaseModel subclass.
|
||||
# Reject async before wrapping (wrapper would hide it from _create_tool)
|
||||
if asyncio.iscoroutinefunction(fn):
|
||||
raise ValidationError(
|
||||
f'experimental.tool: "{fn.__name__}" is async. '
|
||||
f"The Composio Python SDK is synchronous — use a regular "
|
||||
f"'def {fn.__name__}(input, ctx)' instead of 'async def'."
|
||||
)
|
||||
|
||||
sig = inspect.signature(fn)
|
||||
params = list(sig.parameters.values())
|
||||
resolved_annotations = _resolve_function_annotations(
|
||||
fn,
|
||||
localns=annotation_locals,
|
||||
)
|
||||
|
||||
if not params:
|
||||
raise ValidationError(
|
||||
f'experimental.tool: "{fn.__name__}" must accept at least one parameter '
|
||||
f"annotated with a Pydantic BaseModel subclass, e.g. "
|
||||
f"def {fn.__name__}(input: MyInput, ctx): ..."
|
||||
)
|
||||
|
||||
if len(params) > 2:
|
||||
raise ValidationError(
|
||||
f'experimental.tool: "{fn.__name__}" accepts {len(params)} parameters, '
|
||||
f"but custom tools accept at most 2: (input: BaseModel, ctx). "
|
||||
f"Extra parameters will not be populated at runtime."
|
||||
)
|
||||
|
||||
# First param must be the input model
|
||||
first = params[0]
|
||||
first_annotation = resolved_annotations.get(first.name, first.annotation)
|
||||
if first_annotation is inspect.Parameter.empty or not (
|
||||
isinstance(first_annotation, type) and issubclass(first_annotation, BaseModel)
|
||||
):
|
||||
raise ValidationError(
|
||||
f'experimental.tool: first parameter of "{fn.__name__}" must be annotated '
|
||||
f"with a Pydantic BaseModel subclass. Got: {first_annotation!r}. "
|
||||
f"Expected: def {fn.__name__}(input: MyInput, ctx): ..."
|
||||
)
|
||||
input_params: t.Type[BaseModel] = first_annotation
|
||||
|
||||
# Wrap function to match CustomToolExecuteFn: (input, ctx) -> dict
|
||||
if len(params) == 1:
|
||||
|
||||
def execute(input: t.Any, ctx: t.Any) -> t.Dict[str, t.Any]:
|
||||
return fn(input)
|
||||
else:
|
||||
execute = fn
|
||||
|
||||
return _create_tool(
|
||||
slug=actual_slug,
|
||||
name=actual_name,
|
||||
description=actual_description,
|
||||
input_params=input_params,
|
||||
execute=execute,
|
||||
extends_toolkit=extends_toolkit,
|
||||
output_params=output_params,
|
||||
preload=preload,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# ExperimentalToolkit — custom toolkit with .tool() decorator
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ExperimentalToolkit:
|
||||
"""Custom toolkit that groups related tools under one namespace.
|
||||
|
||||
Use ``@toolkit.tool()`` to add tools. Pass the toolkit to
|
||||
``composio.create(experimental={"custom_toolkits": [toolkit]})``.
|
||||
|
||||
Tools added to a toolkit must NOT use ``extends_toolkit`` — they
|
||||
inherit the toolkit identity instead.
|
||||
|
||||
Example::
|
||||
|
||||
dev_tools = composio.experimental.Toolkit(
|
||||
slug="DEV_TOOLS",
|
||||
name="Dev Tools",
|
||||
description="Local dev utilities",
|
||||
)
|
||||
|
||||
@dev_tools.tool()
|
||||
def search_code(input: SearchInput, ctx):
|
||||
\"\"\"Search developer resources.\"\"\"
|
||||
return {"results": []}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str,
|
||||
preload: t.Optional[bool] = None,
|
||||
) -> None:
|
||||
context = "experimental.Toolkit"
|
||||
_validate_slug(slug, context)
|
||||
if not name:
|
||||
raise ValidationError(f"{context}: name is required")
|
||||
if not description:
|
||||
raise ValidationError(f"{context}: description is required")
|
||||
|
||||
self.slug = slug
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.preload = preload
|
||||
self._tools: t.List[CustomTool] = []
|
||||
|
||||
@property
|
||||
def tools(self) -> t.Tuple[CustomTool, ...]:
|
||||
return tuple(self._tools)
|
||||
|
||||
@t.overload
|
||||
def tool(self, fn: t.Callable[..., t.Any], /) -> CustomTool: ...
|
||||
|
||||
@t.overload
|
||||
def tool(
|
||||
self,
|
||||
*,
|
||||
slug: t.Optional[str] = None,
|
||||
name: t.Optional[str] = None,
|
||||
description: t.Optional[str] = None,
|
||||
output_params: t.Optional[t.Type[BaseModel]] = None,
|
||||
preload: t.Optional[bool] = None,
|
||||
) -> t.Callable[[t.Callable[..., t.Any]], CustomTool]: ...
|
||||
|
||||
def tool(
|
||||
self,
|
||||
fn: t.Optional[t.Callable[..., t.Any]] = None,
|
||||
*,
|
||||
slug: t.Optional[str] = None,
|
||||
name: t.Optional[str] = None,
|
||||
description: t.Optional[str] = None,
|
||||
output_params: t.Optional[t.Type[BaseModel]] = None,
|
||||
preload: t.Optional[bool] = None,
|
||||
) -> t.Union[CustomTool, t.Callable[[t.Callable[..., t.Any]], CustomTool]]:
|
||||
"""Decorator to add a tool to this toolkit.
|
||||
|
||||
Infers slug, name, description, and input_params from the function
|
||||
if not explicitly provided.
|
||||
"""
|
||||
|
||||
def decorator(f: t.Callable[..., t.Any]) -> CustomTool:
|
||||
annotation_locals = _get_caller_locals()
|
||||
custom_tool = _infer_tool_from_function(
|
||||
f,
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
output_params=output_params,
|
||||
preload=preload,
|
||||
annotation_locals=annotation_locals,
|
||||
# No extends_toolkit for toolkit tools
|
||||
)
|
||||
_validate_slug_length(
|
||||
custom_tool.slug, self.slug, f'experimental.Toolkit("{self.slug}").tool'
|
||||
)
|
||||
self._tools.append(custom_tool)
|
||||
return custom_tool
|
||||
|
||||
if fn is not None:
|
||||
custom_tool = _infer_tool_from_function(
|
||||
fn,
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
output_params=output_params,
|
||||
preload=preload,
|
||||
annotation_locals=_get_caller_locals(),
|
||||
)
|
||||
_validate_slug_length(
|
||||
custom_tool.slug,
|
||||
self.slug,
|
||||
f'experimental.Toolkit("{self.slug}").tool',
|
||||
)
|
||||
self._tools.append(custom_tool)
|
||||
return custom_tool
|
||||
return decorator
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Serialization (for backend API payload)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _serialized_preload_value(
|
||||
preload: t.Optional[bool],
|
||||
inherited_preload: t.Optional[bool],
|
||||
default_preload: bool,
|
||||
) -> t.Optional[bool]:
|
||||
inherited_or_default = (
|
||||
inherited_preload if inherited_preload is not None else default_preload
|
||||
)
|
||||
if preload is not None:
|
||||
return preload if preload or inherited_or_default else None
|
||||
if inherited_preload is not None:
|
||||
return inherited_preload if inherited_preload or default_preload else None
|
||||
return True if default_preload else None
|
||||
|
||||
|
||||
def serialize_custom_tools(
|
||||
tools: t.List[CustomTool],
|
||||
*,
|
||||
default_preload: bool = False,
|
||||
) -> t.List[CustomToolWireDefinition]:
|
||||
"""Serialize custom tools into the format expected by the backend."""
|
||||
result: t.List[CustomToolWireDefinition] = []
|
||||
for tool in tools:
|
||||
entry: CustomToolWireDefinition = {
|
||||
"slug": tool.slug,
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"input_schema": tool.input_schema,
|
||||
}
|
||||
if tool.output_schema:
|
||||
entry["output_schema"] = tool.output_schema
|
||||
if tool.extends_toolkit:
|
||||
entry["extends_toolkit"] = tool.extends_toolkit
|
||||
preload = _serialized_preload_value(
|
||||
tool.preload, inherited_preload=None, default_preload=default_preload
|
||||
)
|
||||
if preload is not None:
|
||||
entry["preload"] = preload
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def serialize_custom_toolkits(
|
||||
toolkits: t.Sequence[ExperimentalToolkit],
|
||||
*,
|
||||
default_preload: bool = False,
|
||||
) -> t.List[CustomToolkitWireDefinition]:
|
||||
"""Serialize custom toolkits into the format expected by the backend."""
|
||||
result: t.List[CustomToolkitWireDefinition] = []
|
||||
for tk in toolkits:
|
||||
toolkit_tools: t.List[CustomToolWireDefinition] = []
|
||||
for tool in tk.tools:
|
||||
entry: CustomToolWireDefinition = {
|
||||
"slug": tool.slug,
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"input_schema": tool.input_schema,
|
||||
}
|
||||
if tool.output_schema:
|
||||
entry["output_schema"] = tool.output_schema
|
||||
preload = _serialized_preload_value(
|
||||
tool.preload,
|
||||
inherited_preload=tk.preload,
|
||||
default_preload=default_preload,
|
||||
)
|
||||
if preload is not None:
|
||||
entry["preload"] = preload
|
||||
toolkit_tools.append(entry)
|
||||
toolkit_entry: CustomToolkitWireDefinition = {
|
||||
"slug": tk.slug,
|
||||
"name": tk.name,
|
||||
"description": tk.description,
|
||||
"tools": toolkit_tools,
|
||||
}
|
||||
preload = _serialized_preload_value(
|
||||
tk.preload, inherited_preload=None, default_preload=default_preload
|
||||
)
|
||||
if preload is not None:
|
||||
toolkit_entry["preload"] = preload
|
||||
result.append(toolkit_entry)
|
||||
return result
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Routing map builders
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_custom_tools_map(
|
||||
tools: t.List[CustomTool],
|
||||
toolkits: t.Optional[t.List[ExperimentalToolkit]] = None,
|
||||
) -> CustomToolsMap:
|
||||
"""Build a CustomToolsMap from custom tools and toolkits."""
|
||||
by_final_slug: t.Dict[str, CustomToolsMapEntry] = {}
|
||||
by_original_slug: t.Dict[str, CustomToolsMapEntry] = {}
|
||||
|
||||
def add_entry(
|
||||
handle: CustomTool, final_slug: str, toolkit: t.Optional[str]
|
||||
) -> None:
|
||||
original_slug = handle.slug.upper()
|
||||
# Custom tool slugs are matched case-insensitively across local and response maps.
|
||||
final_slug_key = final_slug.upper()
|
||||
|
||||
if len(final_slug) > MAX_SLUG_LENGTH:
|
||||
raise ValidationError(
|
||||
f'Custom tool slug "{handle.slug}" produces final slug '
|
||||
f'"{final_slug}" which exceeds {MAX_SLUG_LENGTH} characters.'
|
||||
)
|
||||
|
||||
if final_slug_key in by_final_slug:
|
||||
raise ValidationError(
|
||||
f'Custom tool slug collision: "{final_slug}" is already registered.'
|
||||
)
|
||||
|
||||
if original_slug in by_original_slug:
|
||||
existing = by_original_slug[original_slug]
|
||||
raise ValidationError(
|
||||
f'Custom tool slug collision: original slug "{handle.slug}" '
|
||||
f"maps to multiple final slugs. "
|
||||
f'"{existing.final_slug}" and "{final_slug}" both resolve '
|
||||
f'from "{original_slug}".'
|
||||
)
|
||||
|
||||
entry = CustomToolsMapEntry(
|
||||
handle=handle, final_slug=final_slug, toolkit=toolkit
|
||||
)
|
||||
by_final_slug[final_slug_key] = entry
|
||||
by_original_slug[original_slug] = entry
|
||||
|
||||
# Process standalone tools
|
||||
for handle in tools:
|
||||
add_entry(
|
||||
handle,
|
||||
_build_final_slug(handle.slug, handle.extends_toolkit),
|
||||
handle.extends_toolkit,
|
||||
)
|
||||
|
||||
# Process toolkit tools
|
||||
if toolkits:
|
||||
for tk in toolkits:
|
||||
for handle in tk.tools:
|
||||
add_entry(handle, _build_final_slug(handle.slug, tk.slug), tk.slug)
|
||||
|
||||
return CustomToolsMap(
|
||||
by_final_slug=by_final_slug,
|
||||
by_original_slug=by_original_slug,
|
||||
toolkits=list(toolkits) if toolkits else None,
|
||||
tools=list(tools) if tools else None,
|
||||
)
|
||||
|
||||
|
||||
def build_custom_tools_map_from_response(
|
||||
tools: t.List[CustomTool],
|
||||
toolkits: t.Optional[t.List[ExperimentalToolkit]],
|
||||
experimental: t.Optional[
|
||||
t.Union[
|
||||
"SessionAttachResponseExperimental",
|
||||
"SessionCreateResponseExperimental",
|
||||
"SessionRetrieveResponseExperimental",
|
||||
]
|
||||
],
|
||||
) -> CustomToolsMap:
|
||||
"""Build a CustomToolsMap using the slug/original_slug mapping from the backend response."""
|
||||
by_final_slug: t.Dict[str, CustomToolsMapEntry] = {}
|
||||
by_original_slug: t.Dict[str, CustomToolsMapEntry] = {}
|
||||
|
||||
# Build lookup from original slug -> handle + toolkit
|
||||
handles_by_original: t.Dict[str, t.Tuple[CustomTool, t.Optional[str]]] = {}
|
||||
for handle in tools:
|
||||
key = handle.slug.upper()
|
||||
if key in handles_by_original:
|
||||
raise ValidationError(
|
||||
f'Duplicate custom tool slug "{handle.slug}" — '
|
||||
f"each tool must have a unique slug across all custom tools and toolkits."
|
||||
)
|
||||
handles_by_original[key] = (handle, handle.extends_toolkit)
|
||||
if toolkits:
|
||||
for tk in toolkits:
|
||||
for handle in tk.tools:
|
||||
key = handle.slug.upper()
|
||||
if key in handles_by_original:
|
||||
raise ValidationError(
|
||||
f'Duplicate custom tool slug "{handle.slug}" — '
|
||||
f"each tool must have a unique slug across all custom tools and toolkits."
|
||||
)
|
||||
handles_by_original[key] = (handle, tk.slug)
|
||||
|
||||
def add_entry(
|
||||
final_slug: str, original_slug: str, toolkit: t.Optional[str]
|
||||
) -> None:
|
||||
match = handles_by_original.get(original_slug.upper())
|
||||
if not match:
|
||||
return
|
||||
handle, default_toolkit = match
|
||||
resolved_toolkit = toolkit if toolkit is not None else default_toolkit
|
||||
entry = CustomToolsMapEntry(
|
||||
handle=handle, final_slug=final_slug, toolkit=resolved_toolkit
|
||||
)
|
||||
by_final_slug[final_slug.upper()] = entry
|
||||
by_original_slug[original_slug.upper()] = entry
|
||||
|
||||
if experimental and experimental.custom_tools:
|
||||
for ct in experimental.custom_tools:
|
||||
add_entry(ct.slug, ct.original_slug, ct.extends_toolkit)
|
||||
|
||||
if experimental and experimental.custom_toolkits:
|
||||
for ctk in experimental.custom_toolkits:
|
||||
for ctk_tool in ctk.tools:
|
||||
add_entry(ctk_tool.slug, ctk_tool.original_slug, ctk.slug)
|
||||
|
||||
return CustomToolsMap(
|
||||
by_final_slug=by_final_slug,
|
||||
by_original_slug=by_original_slug,
|
||||
toolkits=list(toolkits) if toolkits else None,
|
||||
tools=list(tools) if tools else None,
|
||||
)
|
||||
|
||||
|
||||
def find_custom_tool_map_entry_by_final_slug(
|
||||
custom_tools_map: t.Optional[CustomToolsMap],
|
||||
slug: str,
|
||||
) -> t.Optional[CustomToolsMapEntry]:
|
||||
"""Find a custom tool entry by final slug only."""
|
||||
if custom_tools_map is None:
|
||||
return None
|
||||
return custom_tools_map.by_final_slug.get(slug.upper())
|
||||
|
||||
|
||||
def assert_no_custom_tool_slugs_in_preload(
|
||||
preload_tools: t.Union[t.Sequence[str], t.Literal["all"], None],
|
||||
custom_tools_map: t.Optional[CustomToolsMap],
|
||||
) -> None:
|
||||
"""Reject legacy top-level preload of custom tool slugs."""
|
||||
if preload_tools is None or preload_tools == PRELOAD_TOOLS_ALL:
|
||||
return
|
||||
if isinstance(preload_tools, str):
|
||||
raise ValidationError(
|
||||
'preload.tools must be a list of Composio tool slugs or "all". '
|
||||
"Set preload=True on the SDK custom tool or custom toolkit "
|
||||
"definition to expose custom tools directly."
|
||||
)
|
||||
|
||||
custom_preload_slugs = []
|
||||
for slug in preload_tools:
|
||||
normalized = slug.upper()
|
||||
if normalized.startswith(LOCAL_TOOL_PREFIX) or (
|
||||
# Top-level preload.tools is only for Composio-managed tool slugs.
|
||||
# Custom tools use preload=True on their SDK definitions instead.
|
||||
custom_tools_map is not None
|
||||
and (
|
||||
normalized in custom_tools_map.by_original_slug
|
||||
or normalized in custom_tools_map.by_final_slug
|
||||
)
|
||||
):
|
||||
custom_preload_slugs.append(slug)
|
||||
|
||||
if custom_preload_slugs:
|
||||
raise ValidationError(
|
||||
"Custom tool slugs are not supported in preload.tools: "
|
||||
f"{', '.join(custom_preload_slugs)}. Set preload=True on the SDK "
|
||||
"custom tool or custom toolkit definition instead."
|
||||
)
|
||||
|
||||
|
||||
def get_preloaded_custom_tool_slugs(
|
||||
custom_tools_map: t.Optional[CustomToolsMap],
|
||||
*,
|
||||
default_preload: bool = False,
|
||||
) -> t.List[str]:
|
||||
"""Return final custom tool slugs selected locally for preload."""
|
||||
if custom_tools_map is None:
|
||||
return []
|
||||
|
||||
seen: t.Set[str] = set()
|
||||
custom_tool_slugs: t.List[str] = []
|
||||
|
||||
for entry in custom_tools_map.by_final_slug.values():
|
||||
toolkit = next(
|
||||
(
|
||||
tk
|
||||
for tk in custom_tools_map.toolkits or []
|
||||
if entry.toolkit and tk.slug.lower() == entry.toolkit.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
should_preload = (
|
||||
entry.handle.preload
|
||||
if entry.handle.preload is not None
|
||||
else toolkit.preload
|
||||
if toolkit is not None and toolkit.preload is not None
|
||||
else default_preload
|
||||
)
|
||||
if not should_preload:
|
||||
continue
|
||||
|
||||
final_slug_key = entry.final_slug.upper()
|
||||
if final_slug_key in seen:
|
||||
continue
|
||||
|
||||
seen.add(final_slug_key)
|
||||
custom_tool_slugs.append(entry.final_slug)
|
||||
|
||||
return custom_tool_slugs
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Standalone functions for custom tool lookup and execution.
|
||||
|
||||
Extracted for reuse in SessionContextImpl (sibling routing).
|
||||
|
||||
Security invariant (CWE-639 / SEC-365): on this code path the trusted
|
||||
``user_id`` arrives via ``SessionContext`` — never via ``arguments``. The
|
||||
``arguments`` dict is LLM-supplied and is validated through the tool's
|
||||
Pydantic ``input_params`` model, which discards unknown fields, so an
|
||||
attempt to smuggle ``user_id`` (or any other identity-bearing key) inside
|
||||
``arguments`` cannot reach the tool's execute function or auth lookup.
|
||||
Keep this property when modifying ``execute_custom_tool``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from .custom_tool_types import (
|
||||
CustomToolsMap,
|
||||
CustomToolsMapEntry,
|
||||
SessionContext,
|
||||
)
|
||||
from .tools import ToolExecutionResponse
|
||||
|
||||
|
||||
def find_custom_tool(
|
||||
map_: t.Optional[CustomToolsMap],
|
||||
slug: str,
|
||||
) -> t.Optional[CustomToolsMapEntry]:
|
||||
"""Find a custom tool entry by slug.
|
||||
|
||||
Checks both the final slug map (LOCAL_X — agent/LLM path)
|
||||
and original slug map (X — programmatic path). Case-insensitive.
|
||||
"""
|
||||
if map_ is None:
|
||||
return None
|
||||
upper = slug.upper()
|
||||
return map_.by_final_slug.get(upper) or map_.by_original_slug.get(upper)
|
||||
|
||||
|
||||
def execute_custom_tool(
|
||||
entry: CustomToolsMapEntry,
|
||||
arguments: t.Dict[str, t.Any],
|
||||
session_context: SessionContext,
|
||||
) -> ToolExecutionResponse:
|
||||
"""Execute a custom tool in-process.
|
||||
|
||||
Validates input via the Pydantic model, calls the user's execute function,
|
||||
and wraps the result into the standard response format.
|
||||
"""
|
||||
handle = entry.handle
|
||||
|
||||
# Validate and transform input using the Pydantic model.
|
||||
# This applies defaults, coercions, and validators.
|
||||
try:
|
||||
validated = handle.input_params.model_validate(arguments)
|
||||
except PydanticValidationError as e:
|
||||
return {
|
||||
"data": {},
|
||||
"error": f"Input validation failed: {e}",
|
||||
"successful": False,
|
||||
}
|
||||
|
||||
try:
|
||||
# User's execute returns data directly — we wrap into {data, error, successful}
|
||||
data = handle.execute(validated, session_context)
|
||||
return {
|
||||
"data": data if data is not None else {},
|
||||
"error": None,
|
||||
"successful": True,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"data": {},
|
||||
"error": str(e),
|
||||
"successful": False,
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Type definitions for custom tools in tool router sessions.
|
||||
|
||||
Mirrors the TypeScript types in ts/packages/core/src/types/customTool.types.ts
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing as t
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import typing_extensions as te
|
||||
from composio_client.types.tool_router import session_create_params
|
||||
from pydantic import BaseModel
|
||||
|
||||
from composio_client.types.tool_router.session_execute_response import (
|
||||
SessionExecuteResponse,
|
||||
)
|
||||
from composio_client.types.tool_router.session_proxy_execute_response import (
|
||||
SessionProxyExecuteResponse,
|
||||
)
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Constants
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
LOCAL_TOOL_PREFIX = "LOCAL_"
|
||||
MAX_SLUG_LENGTH = 60
|
||||
SLUG_REGEX = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Execute function type
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
CustomToolExecuteFn = t.Callable[
|
||||
[t.Any, "SessionContext"],
|
||||
t.Dict[str, t.Any],
|
||||
]
|
||||
"""
|
||||
Execute function for custom tools.
|
||||
|
||||
Signature: (input: BaseModel, ctx: SessionContext) -> dict
|
||||
|
||||
Just return the result data, or raise an error. The SDK wraps it internally
|
||||
into {data, error, successful}.
|
||||
"""
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# SessionContext protocol
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SessionContext(te.Protocol):
|
||||
"""Session context injected into custom tool execute functions at runtime.
|
||||
|
||||
Provides identity context and methods to call other tools or proxy API requests.
|
||||
"""
|
||||
|
||||
@property
|
||||
def user_id(self) -> str: ...
|
||||
|
||||
def execute(
|
||||
self,
|
||||
tool_slug: str,
|
||||
arguments: t.Dict[str, t.Any],
|
||||
) -> SessionExecuteResponse:
|
||||
"""Execute any Composio tool from within a custom tool.
|
||||
|
||||
Returns the same response model as ``session.execute()``.
|
||||
"""
|
||||
...
|
||||
|
||||
def proxy_execute(
|
||||
self,
|
||||
*,
|
||||
toolkit: str,
|
||||
endpoint: str,
|
||||
method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
body: t.Any = None,
|
||||
parameters: t.Optional[t.List[t.Dict[str, t.Any]]] = None,
|
||||
) -> SessionProxyExecuteResponse:
|
||||
"""Proxy API calls through Composio's auth layer.
|
||||
|
||||
Returns the same response model as ``session.proxy_execute()``.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Custom tool / toolkit definitions (returned by factory functions)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomTool:
|
||||
"""Custom tool definition returned from ``@composio.experimental.tool()``.
|
||||
|
||||
Pass to ``composio.create(user_id, experimental={"custom_tools": [...]})``
|
||||
to bind to a session.
|
||||
"""
|
||||
|
||||
slug: str
|
||||
name: str
|
||||
description: str
|
||||
input_schema: t.Dict[str, t.Any]
|
||||
input_params: t.Type[BaseModel]
|
||||
execute: CustomToolExecuteFn
|
||||
extends_toolkit: t.Optional[str] = None
|
||||
output_schema: t.Optional[t.Dict[str, t.Any]] = None
|
||||
preload: t.Optional[bool] = None
|
||||
|
||||
|
||||
CustomToolWireDefinition = session_create_params.ExperimentalCustomTool
|
||||
CustomToolkitWireDefinition = session_create_params.ExperimentalCustomToolkit
|
||||
|
||||
|
||||
class InlineCustomToolsWirePayload(te.TypedDict, total=False):
|
||||
custom_tools: t.List[CustomToolWireDefinition]
|
||||
custom_toolkits: t.List[CustomToolkitWireDefinition]
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Internal routing map types
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomToolsMapEntry:
|
||||
"""Entry in the per-session custom tools routing map."""
|
||||
|
||||
handle: CustomTool
|
||||
final_slug: str
|
||||
toolkit: t.Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomToolsMap:
|
||||
"""Lookup maps used by ToolRouterSession for routing custom tools."""
|
||||
|
||||
by_final_slug: t.Dict[str, CustomToolsMapEntry] = field(default_factory=dict)
|
||||
by_original_slug: t.Dict[str, CustomToolsMapEntry] = field(default_factory=dict)
|
||||
toolkits: t.Optional[t.List[t.Any]] = None
|
||||
tools: t.Optional[t.List["CustomTool"]] = None
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Registered types (returned by session.custom_tools() / .custom_toolkits())
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegisteredCustomTool:
|
||||
"""A custom tool as registered in a session, with its final resolved slug."""
|
||||
|
||||
slug: str
|
||||
name: str
|
||||
description: str
|
||||
input_schema: t.Dict[str, t.Any]
|
||||
toolkit: t.Optional[str] = None
|
||||
output_schema: t.Optional[t.Dict[str, t.Any]] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegisteredCustomToolkit:
|
||||
"""A custom toolkit as registered in a session, with final slugs on nested tools."""
|
||||
|
||||
slug: str
|
||||
name: str
|
||||
description: str
|
||||
tools: t.List[RegisteredCustomTool]
|
||||
@@ -0,0 +1,187 @@
|
||||
"""The ``composio.experimental`` namespace.
|
||||
|
||||
Houses experimental SDK surfaces whose shape may change in future
|
||||
releases. Two flavours live here today:
|
||||
|
||||
- Decorators for in-process custom tools and toolkits
|
||||
(``composio.experimental.tool`` / ``composio.experimental.Toolkit``).
|
||||
Implementation details for these still live in :mod:`custom_tool`;
|
||||
this module just exposes them on the namespace.
|
||||
- Experimental SDK methods that take a Composio client
|
||||
(``composio.experimental.update_acl``).
|
||||
|
||||
Anything new on the ``composio.experimental`` namespace should land here,
|
||||
not on the underlying model modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from composio.client import HttpClient
|
||||
from composio.client.types import connected_account_patch_response
|
||||
|
||||
from .custom_tool import (
|
||||
CustomTool,
|
||||
ExperimentalToolkit,
|
||||
_get_caller_locals,
|
||||
_infer_tool_from_function,
|
||||
)
|
||||
|
||||
# Server-side 400 message the API uses to reject ACL writes against a
|
||||
# PRIVATE connection. Substring-matched in `update_acl` here and in the
|
||||
# sibling `link()` / `authorize()` call sites — kept as a single constant
|
||||
# so a server-side message tweak only requires one edit.
|
||||
ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT = "acl_config_for_shared is only valid on SHARED"
|
||||
|
||||
|
||||
class ExperimentalAPI:
|
||||
"""Experimental APIs accessed via ``composio.experimental``.
|
||||
|
||||
Provides decorators for creating custom tools and toolkits that run
|
||||
in-process alongside remote Composio tools, plus experimental SDK
|
||||
methods whose shape may change in future releases.
|
||||
"""
|
||||
|
||||
Toolkit = ExperimentalToolkit
|
||||
|
||||
def __init__(self, client: t.Optional[HttpClient] = None) -> None:
|
||||
self._client = client
|
||||
|
||||
def update_acl(
|
||||
self,
|
||||
nanoid: str,
|
||||
*,
|
||||
allow_all_users: t.Optional[bool] = None,
|
||||
allowed_user_ids: t.Optional[t.List[str]] = None,
|
||||
not_allowed_user_ids: t.Optional[t.List[str]] = None,
|
||||
) -> connected_account_patch_response.ConnectedAccountPatchResponse:
|
||||
"""
|
||||
Update the per-user ACL on a SHARED connected account. Experimental —
|
||||
shape may change in future releases.
|
||||
|
||||
Only valid on SHARED connections; raises
|
||||
``ComposioAclOnlyForSharedError`` on a PRIVATE connection. Omit a
|
||||
parameter to leave it unchanged; pass an empty list to clear an
|
||||
allow/deny list. At least one parameter must be provided.
|
||||
|
||||
:param nanoid: The connected account ID (``ca_xxx``).
|
||||
:param allow_all_users: When True, any ``user_id`` may use this
|
||||
SHARED connection (subject to the deny list).
|
||||
:param allowed_user_ids: Explicit list of allowed ``user_id`` strings.
|
||||
Pass ``[]`` to clear.
|
||||
:param not_allowed_user_ids: Explicit deny list (wins over allow on
|
||||
conflict). Pass ``[]`` to clear — note that clearing the deny
|
||||
list silently re-grants access to previously-blocked users.
|
||||
:return: Response with ``id``, ``status``, and ``success``.
|
||||
|
||||
.. deprecated::
|
||||
Use :meth:`composio.connected_accounts.update_acl` instead — ACL
|
||||
updates graduated onto the ``connected_accounts`` model. This
|
||||
experimental alias is kept only for backwards compatibility and
|
||||
delegates to it. Prefer the ``connected_accounts`` model; do not
|
||||
generate new code against this alias.
|
||||
|
||||
Example:
|
||||
composio.connected_accounts.update_acl(
|
||||
'ca_abc',
|
||||
allow_all_users=True,
|
||||
not_allowed_user_ids=['user_bob'],
|
||||
)
|
||||
"""
|
||||
from composio import exceptions
|
||||
|
||||
from .connected_accounts import ConnectedAccounts
|
||||
|
||||
if self._client is None:
|
||||
raise exceptions.ValidationError(
|
||||
"update_acl requires a Composio client. Access it via "
|
||||
"composio.connected_accounts.update_acl(...)."
|
||||
)
|
||||
|
||||
return ConnectedAccounts(client=self._client).update_acl(
|
||||
nanoid,
|
||||
allow_all_users=allow_all_users,
|
||||
allowed_user_ids=allowed_user_ids,
|
||||
not_allowed_user_ids=not_allowed_user_ids,
|
||||
)
|
||||
|
||||
@t.overload
|
||||
def tool(self, fn: t.Callable[..., t.Any], /) -> CustomTool: ...
|
||||
|
||||
@t.overload
|
||||
def tool(
|
||||
self,
|
||||
*,
|
||||
slug: t.Optional[str] = None,
|
||||
name: t.Optional[str] = None,
|
||||
description: t.Optional[str] = None,
|
||||
extends_toolkit: t.Optional[str] = None,
|
||||
output_params: t.Optional[t.Type[BaseModel]] = None,
|
||||
preload: t.Optional[bool] = None,
|
||||
) -> t.Callable[[t.Callable[..., t.Any]], CustomTool]: ...
|
||||
|
||||
def tool(
|
||||
self,
|
||||
fn: t.Optional[t.Callable[..., t.Any]] = None,
|
||||
*,
|
||||
slug: t.Optional[str] = None,
|
||||
name: t.Optional[str] = None,
|
||||
description: t.Optional[str] = None,
|
||||
extends_toolkit: t.Optional[str] = None,
|
||||
output_params: t.Optional[t.Type[BaseModel]] = None,
|
||||
preload: t.Optional[bool] = None,
|
||||
) -> t.Union[CustomTool, t.Callable[[t.Callable[..., t.Any]], CustomTool]]:
|
||||
"""Decorator to create a custom tool from a function.
|
||||
|
||||
Infers slug, name, description, and input_params from the function.
|
||||
Override any with explicit keyword arguments.
|
||||
|
||||
Examples::
|
||||
|
||||
# Bare decorator — no parens
|
||||
@composio.experimental.tool
|
||||
def grep(input: GrepInput, ctx):
|
||||
\"\"\"Search for a pattern.\"\"\"
|
||||
return {"matches": []}
|
||||
|
||||
# With parens — no args
|
||||
@composio.experimental.tool()
|
||||
def grep(input: GrepInput, ctx):
|
||||
\"\"\"Search for a pattern.\"\"\"
|
||||
return {"matches": []}
|
||||
|
||||
# With extends_toolkit — inherits auth
|
||||
@composio.experimental.tool(extends_toolkit="gmail")
|
||||
def create_draft(input: DraftInput, ctx):
|
||||
\"\"\"Create a Gmail draft.\"\"\"
|
||||
return ctx.proxy_execute(toolkit="gmail", ...)
|
||||
"""
|
||||
|
||||
def decorator(f: t.Callable[..., t.Any]) -> CustomTool:
|
||||
annotation_locals = _get_caller_locals()
|
||||
return _infer_tool_from_function(
|
||||
f,
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
extends_toolkit=extends_toolkit,
|
||||
output_params=output_params,
|
||||
preload=preload,
|
||||
annotation_locals=annotation_locals,
|
||||
)
|
||||
|
||||
if fn is not None:
|
||||
return _infer_tool_from_function(
|
||||
fn,
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
extends_toolkit=extends_toolkit,
|
||||
output_params=output_params,
|
||||
preload=preload,
|
||||
annotation_locals=_get_caller_locals(),
|
||||
)
|
||||
return decorator
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from composio_client import Omit, omit
|
||||
from composio_client.types.tool_router import (
|
||||
session_attach_params,
|
||||
session_execute_params,
|
||||
session_search_params,
|
||||
)
|
||||
|
||||
from composio.core.models.custom_tool_types import InlineCustomToolsWirePayload
|
||||
|
||||
|
||||
def inline_custom_tools_attach_experimental(
|
||||
payload: t.Optional[InlineCustomToolsWirePayload],
|
||||
) -> t.Union[session_attach_params.Experimental, Omit]:
|
||||
if payload is None:
|
||||
return omit
|
||||
# Stainless generates endpoint-specific experimental types with the same custom
|
||||
# definition shape, so this helper centralizes the structural cast.
|
||||
return t.cast(session_attach_params.Experimental, payload)
|
||||
|
||||
|
||||
def inline_custom_tools_execute_experimental(
|
||||
payload: t.Optional[InlineCustomToolsWirePayload],
|
||||
) -> t.Union[session_execute_params.Experimental, Omit]:
|
||||
if payload is None:
|
||||
return omit
|
||||
# Stainless generates endpoint-specific experimental types with the same custom
|
||||
# definition shape, so this helper centralizes the structural cast.
|
||||
return t.cast(session_execute_params.Experimental, payload)
|
||||
|
||||
|
||||
def inline_custom_tools_search_experimental(
|
||||
payload: t.Optional[InlineCustomToolsWirePayload],
|
||||
) -> t.Union[session_search_params.Experimental, Omit]:
|
||||
if payload is None:
|
||||
return omit
|
||||
# Stainless generates endpoint-specific experimental types with the same custom
|
||||
# definition shape, so this helper centralizes the structural cast.
|
||||
return t.cast(session_search_params.Experimental, payload)
|
||||
@@ -0,0 +1,28 @@
|
||||
from composio_client import BaseModel
|
||||
|
||||
from composio.core.models.base import Resource
|
||||
|
||||
INTERNAL_SDK_REALTIME_CREDENTIALS_ENDPOINT = "/api/v3/internal/sdk/realtime/credentials"
|
||||
|
||||
|
||||
class SDKRealtimeCredentialsResponse(BaseModel):
|
||||
pusher_key: str
|
||||
project_id: str
|
||||
pusher_cluster: str
|
||||
|
||||
|
||||
class Internal(Resource):
|
||||
"""
|
||||
Internal resource for getting internal SDK realtime credentials.
|
||||
"""
|
||||
|
||||
def get_sdk_realtime_credentials(self) -> SDKRealtimeCredentialsResponse:
|
||||
"""
|
||||
Get the SDK realtime credentials.
|
||||
|
||||
:return: The SDK realtime credentials.
|
||||
"""
|
||||
return self._client.get(
|
||||
path=INTERNAL_SDK_REALTIME_CREDENTIALS_ENDPOINT,
|
||||
cast_to=SDKRealtimeCredentialsResponse,
|
||||
)
|
||||
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
MCP (Model Control Protocol) module for Composio SDK.
|
||||
|
||||
This module provides MCP server operations
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
import typing_extensions as te
|
||||
from composio_client.types.mcp.custom_create_response import CustomCreateResponse
|
||||
|
||||
from composio.client import HttpClient
|
||||
from composio.core.models.base import Resource
|
||||
from composio.exceptions import ValidationError
|
||||
from composio.utils.pydantic import none_to_omit
|
||||
|
||||
# Data Types (matching TypeScript specification)
|
||||
|
||||
|
||||
class ConfigToolkit(te.TypedDict, total=False):
|
||||
"""Toolkit configuration for an MCP server.
|
||||
|
||||
Defined locally rather than imported from ``composio_client``: the generated
|
||||
client removed ``types.tool_router_create_session_params.ConfigToolkit`` in
|
||||
1.41.0 when the tool-router session schema was redesigned. The MCP surface
|
||||
only needs the toolkit slug and optional auth config, so we keep our own
|
||||
minimal shape and stay decoupled from tool-router regen churn.
|
||||
"""
|
||||
|
||||
toolkit: te.Required[str]
|
||||
"""Toolkit identifier (e.g., gmail, slack, github)."""
|
||||
|
||||
auth_config_id: str
|
||||
"""Specific auth configuration ID for this toolkit."""
|
||||
|
||||
|
||||
class MCPCreateResponse(CustomCreateResponse):
|
||||
"""MCP Create Response with generate method (extends CustomCreateResponse)."""
|
||||
|
||||
generate: t.Callable[[str, t.Optional[bool]], "MCPServerInstance"]
|
||||
|
||||
|
||||
class MCPServerInstance(te.TypedDict):
|
||||
"""MCP Server Instance data structure (matching TypeScript implementation)."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
url: str # User-specific connection URL
|
||||
user_id: str # Associated user ID
|
||||
allowed_tools: t.List[str] # Available tools for the user
|
||||
auth_configs: t.List[str] # Associated auth configurations
|
||||
|
||||
|
||||
class MCPItem(te.TypedDict):
|
||||
"""Complete MCP server information."""
|
||||
|
||||
id: str # Unique server identifier
|
||||
name: str # Human-readable server name
|
||||
allowed_tools: t.List[str] # Array of enabled tool identifiers
|
||||
auth_config_ids: t.List[str] # Array of auth configuration IDs
|
||||
toolkits: t.List[str] # Array of toolkit names
|
||||
commands: t.Dict[str, str] # Setup commands for different clients
|
||||
mcp_url: str # Server connection URL
|
||||
toolkit_icons: t.Dict[str, str] # Map of toolkit icons
|
||||
server_instance_count: int # Number of active instances
|
||||
created_at: te.NotRequired[t.Optional[str]]
|
||||
updated_at: te.NotRequired[t.Optional[str]]
|
||||
|
||||
|
||||
class MCPListResponse(te.TypedDict):
|
||||
"""Paginated list response."""
|
||||
|
||||
items: t.List[t.Any] # Array of MCP server objects (raw API response)
|
||||
current_page: int # Current page number
|
||||
total_pages: int # Total number of pages
|
||||
|
||||
|
||||
def _add_generate_method(
|
||||
response: CustomCreateResponse, mcp_instance: "MCP"
|
||||
) -> MCPCreateResponse:
|
||||
"""Add generate method to CustomCreateResponse object."""
|
||||
|
||||
def generate(
|
||||
user_id: str, manually_manage_connections: t.Optional[bool] = None
|
||||
) -> MCPServerInstance:
|
||||
"""
|
||||
Generate server instance for this MCP configuration.
|
||||
Matches TypeScript server.generate(userId) method.
|
||||
|
||||
:param user_id: External user ID from your database
|
||||
:param manually_manage_connections: Whether to manually manage connections (optional)
|
||||
:return: MCP server instance
|
||||
"""
|
||||
return mcp_instance.generate(user_id, response.id, manually_manage_connections)
|
||||
|
||||
# Add the generate method to the response object and cast to MCPCreateResponse
|
||||
response.generate = generate # type: ignore
|
||||
return t.cast(MCPCreateResponse, response)
|
||||
|
||||
|
||||
class MCP(Resource):
|
||||
"""
|
||||
MCP (Model Control Protocol) class.
|
||||
Provides enhanced MCP server operations
|
||||
|
||||
This matches the TypeScript ExperimentalMCP class functionality.
|
||||
|
||||
.. deprecated::
|
||||
Use a session's MCP endpoint instead:
|
||||
``composio.create(user_id, mcp=True)`` returns a session that exposes
|
||||
``session.mcp.url`` / ``session.mcp.headers``. MCP is now opt-in per
|
||||
session; this standalone ``composio.mcp`` server-management API is kept
|
||||
only for backwards compatibility. Prefer the session MCP endpoint; do
|
||||
not generate new code against ``composio.mcp``.
|
||||
See https://docs.composio.dev/docs/sessions-via-mcp
|
||||
"""
|
||||
|
||||
def __init__(self, client: HttpClient):
|
||||
"""
|
||||
Initialize MCP instance.
|
||||
|
||||
:param client: HTTP client for API calls
|
||||
"""
|
||||
super().__init__(client)
|
||||
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
toolkits: t.List[t.Union[ConfigToolkit, str]],
|
||||
manually_manage_connections: bool = False,
|
||||
allowed_tools: t.Optional[t.List[str]] = None,
|
||||
) -> MCPCreateResponse:
|
||||
"""
|
||||
Create a new MCP server configuration with specified toolkits and authentication settings.
|
||||
|
||||
:param name: Unique name for the MCP configuration
|
||||
:param toolkits: List of toolkit configurations. Can be either:
|
||||
- MCPToolkitConfig objects with detailed configuration
|
||||
- Strings representing toolkit names (for simple cases)
|
||||
:param manually_manage_connections: Whether to manually manage account connections (default: False)
|
||||
:param allowed_tools: List of specific tools to enable across all toolkits (default: None for all tools)
|
||||
:return: Created server details with generate method
|
||||
|
||||
Examples:
|
||||
>>> # Using toolkit configuration objects with auth
|
||||
>>> server = composio.experimental.mcp.create(
|
||||
... 'personal-mcp-server',
|
||||
... toolkits=[
|
||||
... {
|
||||
... 'toolkit': 'github',
|
||||
... 'auth_config_id': 'ac_xyz',
|
||||
... },
|
||||
... {
|
||||
... 'toolkit': 'slack',
|
||||
... 'auth_config_id': 'ac_abc',
|
||||
... },
|
||||
... ],
|
||||
... allowed_tools=['GITHUB_CREATE_ISSUE', 'GITHUB_LIST_REPOS', 'SLACK_SEND_MESSAGE'],
|
||||
... manually_manage_connections=False
|
||||
... )
|
||||
>>>
|
||||
>>> # Using simple toolkit names (most common usage)
|
||||
>>> server = composio.experimental.mcp.create(
|
||||
... 'simple-mcp-server',
|
||||
... toolkits=['composio_search', 'text_to_pdf'],
|
||||
... allowed_tools=['COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH', 'TEXT_TO_PDF_CONVERT_TEXT_TO_PDF']
|
||||
... )
|
||||
>>>
|
||||
>>> # Using all tools from toolkits (default behavior)
|
||||
>>> server = composio.experimental.mcp.create(
|
||||
... 'all-tools-server',
|
||||
... toolkits=['composio_search', 'text_to_pdf']
|
||||
... # allowed_tools=None means all tools from these toolkits
|
||||
... )
|
||||
>>>
|
||||
>>> # Get server instance for a user
|
||||
>>> mcp = server.generate('user_12345')
|
||||
"""
|
||||
if not toolkits:
|
||||
raise ValidationError("At least one toolkit configuration is required")
|
||||
|
||||
try:
|
||||
# Normalize toolkits to MCPToolkitConfig format
|
||||
normalized_toolkit_configs: t.List[ConfigToolkit] = []
|
||||
for toolkit in toolkits:
|
||||
if isinstance(toolkit, str):
|
||||
# Convert string to MCPToolkitConfig
|
||||
normalized_toolkit_configs.append(ConfigToolkit(toolkit=toolkit))
|
||||
else:
|
||||
# Already MCPToolkitConfig, use as-is
|
||||
normalized_toolkit_configs.append(toolkit)
|
||||
|
||||
# Extract toolkits and prepare for API call
|
||||
toolkit_configs = normalized_toolkit_configs
|
||||
|
||||
# Get unique toolkits and auth config IDs
|
||||
toolkit_names = []
|
||||
auth_config_ids = []
|
||||
|
||||
for toolkit_config in toolkit_configs:
|
||||
if (
|
||||
"toolkit" in toolkit_config
|
||||
and toolkit_config["toolkit"] not in toolkit_names
|
||||
):
|
||||
toolkit_names.append(toolkit_config["toolkit"])
|
||||
|
||||
if (
|
||||
"auth_config_id" in toolkit_config
|
||||
and toolkit_config["auth_config_id"] not in auth_config_ids
|
||||
):
|
||||
auth_config_ids.append(toolkit_config["auth_config_id"])
|
||||
|
||||
# Use the allowed_tools parameter instead of individual toolkit configs
|
||||
custom_tools = none_to_omit(allowed_tools)
|
||||
|
||||
# Use the custom MCP create endpoint
|
||||
response = self._client.mcp.custom.create(
|
||||
name=name,
|
||||
toolkits=toolkit_names,
|
||||
auth_config_ids=auth_config_ids,
|
||||
custom_tools=custom_tools,
|
||||
managed_auth_via_composio=not manually_manage_connections,
|
||||
)
|
||||
|
||||
# Return response with generate method (matching TypeScript behavior)
|
||||
return _add_generate_method(response, self)
|
||||
|
||||
except Exception as e:
|
||||
raise ValidationError("Failed to create MCP server") from e
|
||||
|
||||
def list(
|
||||
self,
|
||||
page_no: t.Optional[int] = None,
|
||||
limit: t.Optional[int] = None,
|
||||
toolkits: t.Optional[str] = None,
|
||||
auth_config_ids: t.Optional[str] = None,
|
||||
name: t.Optional[str] = None,
|
||||
order_by: t.Optional[te.Literal["created_at", "updated_at"]] = None,
|
||||
order_direction: t.Optional[te.Literal["asc", "desc"]] = None,
|
||||
) -> MCPListResponse:
|
||||
"""
|
||||
List MCP servers with optional filtering and pagination.
|
||||
|
||||
:param page_no: Page number for pagination (default: 1)
|
||||
:param limit: Maximum items per page (default: 10)
|
||||
:param toolkits: Filter by toolkit name (single string)
|
||||
:param auth_config_ids: Filter by auth configuration ID (single string)
|
||||
:param name: Filter by server name (partial match)
|
||||
:param order_by: Order by field ('created_at' or 'updated_at')
|
||||
:param order_direction: Order direction ('asc' or 'desc')
|
||||
:return: Paginated list of MCP servers
|
||||
|
||||
Examples:
|
||||
>>> # List all servers
|
||||
>>> all_servers = composio.experimental.mcp.list()
|
||||
>>>
|
||||
>>> # List with pagination
|
||||
>>> paged_servers = composio.experimental.mcp.list(page_no=2, limit=5)
|
||||
>>>
|
||||
>>> # Filter by toolkit
|
||||
>>> github_servers = composio.experimental.mcp.list(toolkits='github', name='personal')
|
||||
"""
|
||||
|
||||
try:
|
||||
# Use the MCP list endpoint with filters
|
||||
response = self._client.mcp.list(
|
||||
page_no=page_no,
|
||||
limit=limit,
|
||||
toolkits=none_to_omit(toolkits),
|
||||
auth_config_ids=none_to_omit(auth_config_ids),
|
||||
name=none_to_omit(name),
|
||||
order_by=none_to_omit(order_by),
|
||||
order_direction=none_to_omit(order_direction),
|
||||
)
|
||||
|
||||
items = (
|
||||
response.items if hasattr(response, "items") and response.items else []
|
||||
)
|
||||
|
||||
return MCPListResponse(
|
||||
items=items,
|
||||
current_page=getattr(response, "current_page", page_no or 1),
|
||||
total_pages=getattr(response, "total_pages", 1),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise ValidationError("Failed to list MCP servers") from e
|
||||
|
||||
def get(self, server_id: str):
|
||||
"""
|
||||
Retrieve detailed information about a specific MCP server/config.
|
||||
|
||||
:param server_id: The unique identifier of the MCP server/config
|
||||
:return: Complete MCP server information
|
||||
|
||||
Example:
|
||||
>>> server = composio.experimental.mcp.get('mcp_12345')
|
||||
>>>
|
||||
>>> print(server['name']) # "My Personal MCP Server"
|
||||
>>> print(server['allowed_tools']) # ["GITHUB_CREATE_ISSUE", "SLACK_SEND_MESSAGE"]
|
||||
>>> print(server['toolkits']) # ["github", "slack"]
|
||||
>>> print(server['server_instance_count']) # 3
|
||||
"""
|
||||
try:
|
||||
response = self._client.mcp.retrieve(server_id)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Failed to retrieve MCP server {server_id}") from e
|
||||
|
||||
def update(
|
||||
self,
|
||||
server_id: str,
|
||||
name: t.Optional[str] = None,
|
||||
toolkits: t.Optional[t.List[t.Union[ConfigToolkit, str]]] = None,
|
||||
manually_manage_connections: t.Optional[bool] = None,
|
||||
allowed_tools: t.Optional[t.List[str]] = None,
|
||||
):
|
||||
"""
|
||||
Update an existing MCP server configuration.
|
||||
|
||||
:param server_id: The unique identifier of the MCP server to update
|
||||
:param name: Optional new name for the MCP server
|
||||
:param toolkits: Optional list of toolkit configurations (strings or objects)
|
||||
:param manually_manage_connections: Optional flag for connection management
|
||||
:param allowed_tools: Optional list of specific tools to enable across all toolkits
|
||||
:return: Updated MCP server information
|
||||
|
||||
Examples:
|
||||
>>> # Update server name only
|
||||
>>> updated_server = composio.experimental.mcp.update(
|
||||
... 'mcp_12345',
|
||||
... name='My Updated MCP Server'
|
||||
... )
|
||||
>>>
|
||||
>>> # Update toolkits and tools
|
||||
>>> server_with_new_tools = composio.experimental.mcp.update(
|
||||
... 'mcp_12345',
|
||||
... toolkits=['github', 'slack'],
|
||||
... allowed_tools=['GITHUB_CREATE_ISSUE', 'SLACK_SEND_MESSAGE']
|
||||
... )
|
||||
>>>
|
||||
>>> # Update with auth configs
|
||||
>>> server_with_auth = composio.experimental.mcp.update(
|
||||
... 'mcp_12345',
|
||||
... toolkits=[
|
||||
... {'toolkit': 'github', 'auth_config_id': 'auth_abc123'},
|
||||
... {'toolkit': 'slack', 'auth_config_id': 'auth_def456'}
|
||||
... ],
|
||||
... allowed_tools=['GITHUB_CREATE_ISSUE', 'SLACK_SEND_MESSAGE'],
|
||||
... manually_manage_connections=False
|
||||
... )
|
||||
"""
|
||||
try:
|
||||
update_params: t.Dict[str, t.Any] = {}
|
||||
|
||||
if name is not None:
|
||||
update_params["name"] = name
|
||||
|
||||
if toolkits is not None:
|
||||
# Normalize toolkits to ConfigToolkit format (same as create method)
|
||||
normalized_toolkit_configs: t.List[ConfigToolkit] = []
|
||||
for toolkit in toolkits:
|
||||
if isinstance(toolkit, str):
|
||||
# Convert string to ConfigToolkit
|
||||
normalized_toolkit_configs.append(
|
||||
ConfigToolkit(toolkit=toolkit)
|
||||
)
|
||||
else:
|
||||
# Already ConfigToolkit, use as-is
|
||||
normalized_toolkit_configs.append(toolkit)
|
||||
|
||||
# Extract toolkits and prepare for API call
|
||||
toolkit_names = []
|
||||
auth_config_ids = []
|
||||
|
||||
for toolkit_config in normalized_toolkit_configs:
|
||||
if (
|
||||
"toolkit" in toolkit_config
|
||||
and toolkit_config["toolkit"] not in toolkit_names
|
||||
):
|
||||
toolkit_names.append(toolkit_config["toolkit"])
|
||||
|
||||
if (
|
||||
"auth_config_id" in toolkit_config
|
||||
and toolkit_config["auth_config_id"] not in auth_config_ids
|
||||
):
|
||||
auth_config_ids.append(toolkit_config["auth_config_id"])
|
||||
|
||||
update_params["toolkits"] = toolkit_names
|
||||
update_params["auth_config_ids"] = auth_config_ids
|
||||
|
||||
if allowed_tools is not None:
|
||||
update_params["custom_tools"] = allowed_tools
|
||||
|
||||
if manually_manage_connections is not None:
|
||||
update_params[
|
||||
"managed_auth_via_composio"
|
||||
] = not manually_manage_connections
|
||||
|
||||
# Use the MCP update endpoint
|
||||
response = self._client.mcp.update(server_id, **update_params)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Failed to update MCP server {server_id}") from e
|
||||
|
||||
def delete(self, server_id: str) -> t.Dict[str, t.Any]:
|
||||
"""
|
||||
Permanently delete an MCP server configuration.
|
||||
|
||||
:param server_id: The unique identifier of the MCP server to delete
|
||||
:return: Deletion result
|
||||
|
||||
Example:
|
||||
>>> # Delete a server
|
||||
>>> result = composio.experimental.mcp.delete('mcp_12345')
|
||||
>>>
|
||||
>>> if result['deleted']:
|
||||
... print(f"Server {result['id']} has been successfully deleted")
|
||||
>>> else:
|
||||
... print(f"Failed to delete server {result['id']}")
|
||||
"""
|
||||
try:
|
||||
response = self._client.mcp.delete(server_id)
|
||||
|
||||
return {
|
||||
"id": server_id,
|
||||
"deleted": getattr(response, "deleted", True),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Failed to delete MCP server {server_id}") from e
|
||||
|
||||
def generate(
|
||||
self,
|
||||
user_id: str,
|
||||
mcp_config_id: str,
|
||||
manually_manage_connections: t.Optional[bool] = None,
|
||||
) -> MCPServerInstance:
|
||||
"""
|
||||
Get server URLs for an existing MCP server.
|
||||
|
||||
This matches the TypeScript implementation exactly.
|
||||
|
||||
:param user_id: External user ID from your database
|
||||
:param mcp_config_id: MCP configuration ID
|
||||
:param manually_manage_connections: Whether to manually manage connections (optional)
|
||||
:return: MCP server instance
|
||||
|
||||
Example:
|
||||
>>> mcp = composio.experimental.mcp.generate(
|
||||
... 'user_12345',
|
||||
... 'mcp_67890',
|
||||
... manually_manage_connections=False
|
||||
... )
|
||||
>>>
|
||||
>>> print(mcp['url']) # Server URL for the user
|
||||
>>> print(mcp['allowed_tools']) # Available tools
|
||||
"""
|
||||
|
||||
try:
|
||||
# Get server details first (matching TS: this.client.mcp.retrieve)
|
||||
server_details = self._client.mcp.retrieve(mcp_config_id)
|
||||
|
||||
# Generate server URLs (matching TS: this.client.mcp.generate.url)
|
||||
url_response = self._client.mcp.generate.url(
|
||||
mcp_server_id=mcp_config_id,
|
||||
user_ids=[user_id],
|
||||
managed_auth_by_composio=False if manually_manage_connections else True,
|
||||
)
|
||||
|
||||
# Get the generated URL (matching TS: urlResponse.user_ids_url[0])
|
||||
if hasattr(url_response, "user_ids_url") and url_response.user_ids_url:
|
||||
server_url = url_response.user_ids_url[0]
|
||||
else:
|
||||
raise ValidationError("No server URL generated")
|
||||
|
||||
# Create structured server instance (matching TS MCPServerInstance)
|
||||
server_instance: MCPServerInstance = {
|
||||
"id": server_details.id,
|
||||
"name": server_details.name,
|
||||
"type": "streamable_http",
|
||||
"url": server_url,
|
||||
"user_id": user_id,
|
||||
"allowed_tools": getattr(server_details, "allowed_tools", []),
|
||||
"auth_configs": getattr(server_details, "auth_config_ids", []),
|
||||
}
|
||||
|
||||
return server_instance
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, ValidationError):
|
||||
raise
|
||||
raise ValidationError("Failed to parse MCP server instance") from e
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Session context implementation injected into custom tool execute functions.
|
||||
|
||||
One instance is created per session and shared across all custom tool invocations,
|
||||
including sibling routing (tool A calling tool B without hitting the network).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from composio_client import omit
|
||||
from composio_client.types.tool_router.session_proxy_execute_params import Parameter
|
||||
from composio_client.types.tool_router.session_execute_response import (
|
||||
SessionExecuteResponse,
|
||||
)
|
||||
from composio_client.types.tool_router.session_proxy_execute_response import (
|
||||
SessionProxyExecuteResponse,
|
||||
)
|
||||
from composio.client import HttpClient
|
||||
from composio.core.models.custom_tool_execution import (
|
||||
execute_custom_tool,
|
||||
find_custom_tool,
|
||||
)
|
||||
from composio.core.models.custom_tool_types import (
|
||||
CustomToolsMap,
|
||||
InlineCustomToolsWirePayload,
|
||||
)
|
||||
from composio.core.models.inline_custom_tools_payload import (
|
||||
inline_custom_tools_execute_experimental,
|
||||
)
|
||||
from composio.core.models.tools import _serialize_arguments
|
||||
from composio.exceptions import ValidationError
|
||||
|
||||
|
||||
_VALID_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH"})
|
||||
_VALID_PARAM_TYPES = frozenset({"header", "query"})
|
||||
|
||||
|
||||
def proxy_execute_impl(
|
||||
client: HttpClient,
|
||||
session_id: str,
|
||||
*,
|
||||
toolkit: str,
|
||||
endpoint: str,
|
||||
method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
body: t.Any = None,
|
||||
parameters: t.Optional[t.List[t.Dict[str, t.Any]]] = None,
|
||||
) -> SessionProxyExecuteResponse:
|
||||
"""Shared proxy execute implementation used by SessionContextImpl and ToolRouterSession."""
|
||||
# Client-side validation (matches TS SessionProxyExecuteParamsSchema)
|
||||
if not toolkit:
|
||||
raise ValidationError("proxy_execute: toolkit is required")
|
||||
if not endpoint:
|
||||
raise ValidationError("proxy_execute: endpoint is required")
|
||||
if method not in _VALID_METHODS:
|
||||
raise ValidationError(
|
||||
f"proxy_execute: method must be one of {sorted(_VALID_METHODS)}, got {method!r}"
|
||||
)
|
||||
|
||||
# Transform and validate parameters
|
||||
api_params: t.List[Parameter] = []
|
||||
if parameters:
|
||||
for i, p in enumerate(parameters):
|
||||
if not isinstance(p, dict) or "name" not in p or "value" not in p:
|
||||
raise ValidationError(
|
||||
f"proxy_execute: parameters[{i}] must be a dict with 'name' and 'value' keys"
|
||||
)
|
||||
param_type = p.get("in", p.get("type", "header"))
|
||||
if param_type not in _VALID_PARAM_TYPES:
|
||||
raise ValidationError(
|
||||
f"proxy_execute: parameters[{i}].type must be 'header' or 'query', "
|
||||
f"got {param_type!r}"
|
||||
)
|
||||
api_params.append(
|
||||
Parameter(
|
||||
name=p["name"],
|
||||
type=param_type, # type: ignore[typeddict-item]
|
||||
value=str(p["value"]),
|
||||
)
|
||||
)
|
||||
|
||||
return client.tool_router.session.proxy_execute(
|
||||
session_id=session_id,
|
||||
toolkit_slug=toolkit,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
body=body if body is not None else omit,
|
||||
parameters=api_params if api_params else omit,
|
||||
)
|
||||
|
||||
|
||||
class SessionContextImpl:
|
||||
"""Concrete implementation of SessionContext.
|
||||
|
||||
One instance is created per session (singleton) and shared across
|
||||
all custom tool invocations. When ``custom_tools_map`` is provided,
|
||||
``execute()`` checks local tools first before falling back to the
|
||||
backend API (sibling routing).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: HttpClient,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
custom_tools_map: t.Optional[CustomToolsMap] = None,
|
||||
inline_custom_tools_payload: t.Optional[InlineCustomToolsWirePayload] = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._user_id = user_id
|
||||
self._session_id = session_id
|
||||
self._custom_tools_map = custom_tools_map
|
||||
self._inline_custom_tools_payload = inline_custom_tools_payload
|
||||
|
||||
@property
|
||||
def user_id(self) -> str:
|
||||
"""The user ID for the current session."""
|
||||
return self._user_id
|
||||
|
||||
def execute(
|
||||
self,
|
||||
tool_slug: str,
|
||||
arguments: t.Dict[str, t.Any],
|
||||
) -> SessionExecuteResponse:
|
||||
"""Execute any tool from within a custom tool.
|
||||
|
||||
Routes to sibling local tools in-process when available,
|
||||
otherwise delegates to the backend API.
|
||||
|
||||
Returns the same response model as ``session.execute()``.
|
||||
"""
|
||||
# Try local tool first (sibling routing)
|
||||
entry = find_custom_tool(self._custom_tools_map, tool_slug)
|
||||
if entry:
|
||||
result = execute_custom_tool(entry, arguments, self)
|
||||
return SessionExecuteResponse(
|
||||
data=result["data"],
|
||||
error=result["error"],
|
||||
log_id="",
|
||||
)
|
||||
|
||||
# Serialize any Pydantic model instances before sending to remote API
|
||||
serialized = _serialize_arguments(arguments)
|
||||
|
||||
return self._client.tool_router.session.execute(
|
||||
session_id=self._session_id,
|
||||
tool_slug=tool_slug,
|
||||
arguments=serialized,
|
||||
experimental=inline_custom_tools_execute_experimental(
|
||||
self._inline_custom_tools_payload
|
||||
),
|
||||
)
|
||||
|
||||
def proxy_execute(
|
||||
self,
|
||||
*,
|
||||
toolkit: str,
|
||||
endpoint: str,
|
||||
method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
body: t.Any = None,
|
||||
parameters: t.Optional[t.List[t.Dict[str, t.Any]]] = None,
|
||||
) -> SessionProxyExecuteResponse:
|
||||
"""Proxy API calls through Composio's auth layer.
|
||||
|
||||
Returns the same response model as ``session.proxy_execute()``.
|
||||
"""
|
||||
return proxy_execute_impl(
|
||||
self._client,
|
||||
self._session_id,
|
||||
toolkit=toolkit,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
body=body,
|
||||
parameters=parameters,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
import typing as t
|
||||
|
||||
SESSION_PRESET_DIRECT_TOOLS: t.Literal["direct_tools"] = "direct_tools"
|
||||
PRELOAD_TOOLS_ALL: t.Literal["all"] = "all"
|
||||
@@ -0,0 +1,915 @@
|
||||
"""
|
||||
ToolRouterSession class for managing a single tool router session.
|
||||
|
||||
Provides methods for tools, authorize, toolkits, search, execute, and files.
|
||||
When custom tools are bound to the session, execution is routed: local tools
|
||||
run in-process, remote tools are sent to the backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
|
||||
from composio_client import BadRequestError, Omit, omit
|
||||
from composio_client._types import SequenceNotStr
|
||||
from composio_client.types.tool_list_response import (
|
||||
ItemDeprecated,
|
||||
ItemDeprecatedToolkit,
|
||||
ItemToolkit,
|
||||
)
|
||||
from composio_client.types.tool_router import session_link_params, session_patch_params
|
||||
from composio_client.types.tool_router.session_execute_response import (
|
||||
SessionExecuteResponse,
|
||||
)
|
||||
from composio_client.types.tool_router.session_proxy_execute_response import (
|
||||
SessionProxyExecuteResponse,
|
||||
)
|
||||
from composio_client.types.tool_router.session_search_response import (
|
||||
SessionSearchResponse,
|
||||
)
|
||||
|
||||
from composio import exceptions
|
||||
from composio.client import HttpClient
|
||||
from composio.client.types import Tool
|
||||
from composio.core.models._modifiers import Modifiers, apply_modifier_by_type
|
||||
from composio.core.models.connected_accounts import ConnectionRequest
|
||||
from composio.core.models.custom_tool import find_custom_tool_map_entry_by_final_slug
|
||||
from composio.core.models.custom_tool_execution import (
|
||||
execute_custom_tool,
|
||||
find_custom_tool,
|
||||
)
|
||||
from composio.core.models.custom_tool_types import (
|
||||
CustomToolsMap,
|
||||
CustomToolsMapEntry,
|
||||
InlineCustomToolsWirePayload,
|
||||
RegisteredCustomTool,
|
||||
RegisteredCustomToolkit,
|
||||
)
|
||||
from composio.core.models.experimental import ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT
|
||||
from composio.core.models.inline_custom_tools_payload import (
|
||||
inline_custom_tools_execute_experimental,
|
||||
inline_custom_tools_search_experimental,
|
||||
)
|
||||
from composio.core.models.session_context import SessionContextImpl, proxy_execute_impl
|
||||
from composio.core.models.tool_router_session_delete import (
|
||||
ToolRouterSessionDeleteResponse,
|
||||
delete_tool_router_session,
|
||||
)
|
||||
from composio.core.models.tools import ToolExecuteParams, ToolExecutionResponse
|
||||
from composio.core.provider import TTool, TToolCollection
|
||||
from composio.core.provider.base import BaseProvider
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from composio.core.models.tool_router import (
|
||||
ToolkitConnectionsDetails,
|
||||
ToolRouterMCPServerConfig,
|
||||
ToolRouterSessionExperimental,
|
||||
)
|
||||
|
||||
COMPOSIO_MULTI_EXECUTE_TOOL = "COMPOSIO_MULTI_EXECUTE_TOOL"
|
||||
DIRECT_CUSTOM_TOOL_DESCRIPTION_PREFIX = (
|
||||
"[Direct tool - call directly, no search needed beforehand.]"
|
||||
)
|
||||
MAX_PARALLEL_WORKERS = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolRouterSessionPreloadConfig:
|
||||
"""Preloaded tools configured for a tool router session."""
|
||||
|
||||
tools: t.Union[t.List[str], t.Literal["all"]]
|
||||
|
||||
|
||||
class ToolRouterSession(t.Generic[TTool, TToolCollection]):
|
||||
"""
|
||||
A Composio session — the object returned by ``composio.create(...)`` /
|
||||
``composio.use(...)``. Use it to fetch session-scoped tools, authorize
|
||||
toolkits, search, and execute tools.
|
||||
|
||||
Generic Parameters:
|
||||
TTool: The individual tool type returned by the provider.
|
||||
TToolCollection: The collection type returned by tools().
|
||||
|
||||
The hosted MCP endpoint (``session.mcp``) exists at runtime on every
|
||||
session, but is only surfaced in the type when you opt in with
|
||||
``create(..., mcp=True)`` / ``use(..., mcp=True)``, which returns a
|
||||
:class:`ToolRouterSessionWithMcp`. By default agents use native tools via
|
||||
:meth:`tools`. See https://docs.composio.dev/docs/sessions-via-mcp
|
||||
|
||||
Attributes:
|
||||
session_id: Unique session identifier
|
||||
experimental: Experimental features (files, assistive prompt, etc.)
|
||||
"""
|
||||
|
||||
#: Unique session identifier.
|
||||
session_id: str
|
||||
#: Experimental capabilities available on this session.
|
||||
experimental: "ToolRouterSessionExperimental"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: HttpClient,
|
||||
provider: t.Optional[BaseProvider[t.Any, t.Any]],
|
||||
dangerously_allow_auto_upload_download_files: bool,
|
||||
sensitive_file_upload_protection: bool = True,
|
||||
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = None,
|
||||
session_id: str,
|
||||
mcp: t.Any,
|
||||
experimental: "ToolRouterSessionExperimental",
|
||||
custom_tools_map: t.Optional[CustomToolsMap] = None,
|
||||
user_id: t.Optional[str] = None,
|
||||
preload: t.Optional[ToolRouterSessionPreloadConfig] = None,
|
||||
preloaded_custom_tool_slugs: t.Optional[t.List[str]] = None,
|
||||
inline_custom_tools_payload: t.Optional[InlineCustomToolsWirePayload] = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._provider = provider
|
||||
self._auto_upload_download_files = dangerously_allow_auto_upload_download_files
|
||||
self._sensitive_file_upload_protection = sensitive_file_upload_protection
|
||||
self._file_upload_path_deny_segments = file_upload_path_deny_segments
|
||||
self._file_upload_dirs = file_upload_dirs
|
||||
self.session_id = session_id
|
||||
# The MCP endpoint exists on every session at runtime (kept for
|
||||
# backwards compatibility), but is only typed via
|
||||
# ToolRouterSessionWithMcp. Assign through setattr so type checkers do
|
||||
# not surface `mcp` on the base class — MCP is an explicit opt-in.
|
||||
setattr(self, "mcp", mcp)
|
||||
self.experimental = experimental
|
||||
self.preload = preload or ToolRouterSessionPreloadConfig(tools=[])
|
||||
self._custom_tools_map = custom_tools_map
|
||||
self._user_id = user_id
|
||||
self._preloaded_custom_tool_slugs = preloaded_custom_tool_slugs or []
|
||||
self._inline_custom_tools_payload = inline_custom_tools_payload
|
||||
|
||||
# Create singleton session context if custom tools are bound
|
||||
self._session_context: t.Optional[SessionContextImpl] = None
|
||||
if custom_tools_map and user_id:
|
||||
self._session_context = SessionContextImpl(
|
||||
client=client,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
custom_tools_map=custom_tools_map,
|
||||
inline_custom_tools_payload=inline_custom_tools_payload,
|
||||
)
|
||||
|
||||
def _has_custom_tools(self) -> bool:
|
||||
"""Check if this session has any custom tools bound."""
|
||||
if self._custom_tools_map is None:
|
||||
return False
|
||||
return len(self._custom_tools_map.by_final_slug) > 0
|
||||
|
||||
def _tool_router_backend_execute(
|
||||
self,
|
||||
tools_model: t.Any,
|
||||
modifiers: t.Optional["Modifiers"] = None,
|
||||
) -> t.Callable[..., t.Any]:
|
||||
"""Backend execute wrapper with this session's file-upload settings."""
|
||||
return tools_model._wrap_execute_tool_for_tool_router(
|
||||
session_id=self.session_id,
|
||||
modifiers=modifiers,
|
||||
inline_custom_tools_payload=self._inline_custom_tools_payload,
|
||||
)
|
||||
|
||||
def tools(self, modifiers: t.Optional["Modifiers"] = None) -> TToolCollection:
|
||||
"""
|
||||
Get provider-wrapped tools for execution with your AI framework.
|
||||
|
||||
Returns tools configured for this session, wrapped in the format expected
|
||||
by your AI provider (OpenAI, Anthropic, LangChain, etc.).
|
||||
|
||||
When custom tools are bound to the session, execution of
|
||||
COMPOSIO_MULTI_EXECUTE_TOOL is intercepted: local tools are executed
|
||||
in-process, remote tools are sent to the backend.
|
||||
"""
|
||||
from composio.core.models.tools import Tools as ToolsModel
|
||||
from composio.core.provider import AgenticProvider, NonAgenticProvider
|
||||
|
||||
if self._provider is None:
|
||||
raise ValueError(
|
||||
"Provider is required for tool router. "
|
||||
"Please initialize ToolRouter with a provider."
|
||||
)
|
||||
|
||||
tools_model = ToolsModel(
|
||||
client=self._client,
|
||||
provider=self._provider,
|
||||
dangerously_allow_auto_upload_download_files=self._auto_upload_download_files,
|
||||
sensitive_file_upload_protection=self._sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=self._file_upload_path_deny_segments,
|
||||
file_upload_dirs=self._file_upload_dirs,
|
||||
)
|
||||
|
||||
router_tools = tools_model.get_raw_tool_router_meta_tools(
|
||||
session_id=self.session_id,
|
||||
modifiers=modifiers,
|
||||
)
|
||||
router_tools = self._add_preloaded_custom_tools(router_tools, modifiers)
|
||||
|
||||
for tool in router_tools:
|
||||
tool.input_parameters = (
|
||||
tools_model._file_helper.enhance_schema_descriptions(
|
||||
schema=tool.input_parameters,
|
||||
)
|
||||
)
|
||||
|
||||
if issubclass(type(self._provider), NonAgenticProvider):
|
||||
return t.cast(
|
||||
TToolCollection,
|
||||
t.cast(
|
||||
NonAgenticProvider[TTool, TToolCollection], self._provider
|
||||
).wrap_tools(tools=router_tools),
|
||||
)
|
||||
|
||||
# For agentic providers: if custom tools are bound, create a routing
|
||||
# execute function that intercepts COMPOSIO_MULTI_EXECUTE_TOOL
|
||||
if self._has_custom_tools():
|
||||
execute_fn = self._create_routing_execute_fn(tools_model, modifiers)
|
||||
else:
|
||||
execute_fn = self._tool_router_backend_execute(
|
||||
tools_model, modifiers=modifiers
|
||||
)
|
||||
|
||||
return t.cast(
|
||||
TToolCollection,
|
||||
t.cast(AgenticProvider[TTool, TToolCollection], self._provider).wrap_tools(
|
||||
tools=router_tools,
|
||||
execute_tool=execute_fn,
|
||||
),
|
||||
)
|
||||
|
||||
def _add_preloaded_custom_tools(
|
||||
self,
|
||||
tools: t.List[Tool],
|
||||
modifiers: t.Optional["Modifiers"],
|
||||
) -> t.List[Tool]:
|
||||
custom_tools = self._get_preloaded_custom_tool_schemas(modifiers)
|
||||
if not custom_tools:
|
||||
return tools
|
||||
|
||||
existing_slugs = {tool.slug.upper() for tool in tools}
|
||||
appended_tools = [
|
||||
tool for tool in custom_tools if tool.slug.upper() not in existing_slugs
|
||||
]
|
||||
if not appended_tools:
|
||||
return tools
|
||||
|
||||
return [*tools, *appended_tools]
|
||||
|
||||
def _get_preloaded_custom_tool_schemas(
|
||||
self,
|
||||
modifiers: t.Optional["Modifiers"],
|
||||
) -> t.List[Tool]:
|
||||
if not self._custom_tools_map or not self._preloaded_custom_tool_slugs:
|
||||
return []
|
||||
|
||||
tools: t.List[Tool] = []
|
||||
for slug in self._preloaded_custom_tool_slugs:
|
||||
entry = find_custom_tool_map_entry_by_final_slug(
|
||||
self._custom_tools_map,
|
||||
slug,
|
||||
)
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
tool = self._custom_tool_entry_to_tool(entry)
|
||||
if modifiers is not None:
|
||||
tool = t.cast(
|
||||
Tool,
|
||||
apply_modifier_by_type(
|
||||
modifiers=modifiers,
|
||||
toolkit=tool.toolkit.slug,
|
||||
tool=tool.slug,
|
||||
type="schema",
|
||||
schema=tool,
|
||||
),
|
||||
)
|
||||
tools.append(tool)
|
||||
|
||||
return tools
|
||||
|
||||
def _custom_tool_entry_to_tool(self, entry: CustomToolsMapEntry) -> Tool:
|
||||
toolkit_slug = entry.toolkit or "custom"
|
||||
toolkit_name = (
|
||||
self._custom_toolkit_name(toolkit_slug) or entry.toolkit or "Custom"
|
||||
)
|
||||
|
||||
return Tool(
|
||||
available_versions=[],
|
||||
deprecated=ItemDeprecated(
|
||||
available_versions=[],
|
||||
displayName=entry.handle.name,
|
||||
is_deprecated=False,
|
||||
toolkit=ItemDeprecatedToolkit(logo=""),
|
||||
version="latest",
|
||||
),
|
||||
description=(
|
||||
f"{DIRECT_CUSTOM_TOOL_DESCRIPTION_PREFIX}\n{entry.handle.description}"
|
||||
),
|
||||
input_parameters=entry.handle.input_schema,
|
||||
is_deprecated=False,
|
||||
name=entry.handle.name,
|
||||
no_auth=entry.handle.extends_toolkit is None,
|
||||
output_parameters=entry.handle.output_schema or {},
|
||||
scopes=[],
|
||||
slug=entry.final_slug,
|
||||
tags=[],
|
||||
toolkit=ItemToolkit(logo="", name=toolkit_name, slug=toolkit_slug),
|
||||
version="latest",
|
||||
)
|
||||
|
||||
def _custom_toolkit_name(self, toolkit_slug: str) -> t.Optional[str]:
|
||||
if self._custom_tools_map is None:
|
||||
return None
|
||||
|
||||
for toolkit in self._custom_tools_map.toolkits or []:
|
||||
if toolkit.slug.lower() == toolkit_slug.lower():
|
||||
return toolkit.name
|
||||
return None
|
||||
|
||||
def _create_routing_execute_fn(
|
||||
self,
|
||||
tools_model: t.Any,
|
||||
modifiers: t.Optional["Modifiers"],
|
||||
) -> t.Callable[..., t.Any]:
|
||||
"""Create an execute function that routes local/remote tools.
|
||||
|
||||
Applies before_execute/after_execute modifiers around the overall
|
||||
COMPOSIO_MULTI_EXECUTE_TOOL call, consistent with the standard path.
|
||||
"""
|
||||
backend_execute = self._tool_router_backend_execute(
|
||||
tools_model, modifiers=modifiers
|
||||
)
|
||||
|
||||
def routing_execute(slug: str, arguments: t.Dict) -> t.Dict:
|
||||
if slug == COMPOSIO_MULTI_EXECUTE_TOOL:
|
||||
# Apply before_execute modifiers
|
||||
processed_arguments = arguments
|
||||
if modifiers is not None:
|
||||
type_before: t.Literal["before_execute"] = "before_execute"
|
||||
params: ToolExecuteParams = {"arguments": arguments}
|
||||
modified = apply_modifier_by_type(
|
||||
modifiers=modifiers,
|
||||
toolkit="composio",
|
||||
tool=slug,
|
||||
type=type_before,
|
||||
request=params,
|
||||
)
|
||||
processed_arguments = modified.get("arguments", arguments)
|
||||
|
||||
result = self._route_multi_execute(processed_arguments, tools_model)
|
||||
|
||||
# Apply after_execute modifiers
|
||||
if modifiers is not None:
|
||||
type_after: t.Literal["after_execute"] = "after_execute"
|
||||
result = t.cast(
|
||||
t.Dict[str, t.Any],
|
||||
apply_modifier_by_type(
|
||||
modifiers=modifiers,
|
||||
toolkit="composio",
|
||||
tool=slug,
|
||||
type=type_after,
|
||||
response=t.cast(ToolExecutionResponse, result),
|
||||
),
|
||||
)
|
||||
|
||||
return result
|
||||
entry = find_custom_tool(self._custom_tools_map, slug)
|
||||
if entry:
|
||||
return t.cast(
|
||||
t.Dict[str, t.Any],
|
||||
execute_custom_tool(
|
||||
entry,
|
||||
arguments,
|
||||
t.cast(SessionContextImpl, self._session_context),
|
||||
),
|
||||
)
|
||||
# Non-multi-execute meta tools always go to backend
|
||||
return backend_execute(slug, arguments)
|
||||
|
||||
return routing_execute
|
||||
|
||||
def _parse_tool_item(self, item: t.Any) -> t.Dict[str, t.Any]:
|
||||
"""Parse an individual tool item from COMPOSIO_MULTI_EXECUTE_TOOL's tools array."""
|
||||
if not isinstance(item, dict):
|
||||
return {"tool_slug": "", "arguments": {}}
|
||||
return {
|
||||
"tool_slug": str(item.get("tool_slug", "")),
|
||||
"arguments": item.get("arguments", {}),
|
||||
}
|
||||
|
||||
def _route_multi_execute(
|
||||
self,
|
||||
input_args: t.Dict[str, t.Any],
|
||||
tools_model: t.Any,
|
||||
) -> t.Dict[str, t.Any]:
|
||||
"""Route a COMPOSIO_MULTI_EXECUTE_TOOL call.
|
||||
|
||||
Splits the tools[] array into local and remote, executes each
|
||||
appropriately, and merges results: remotes first, locals appended
|
||||
(matches TS — remote results may have workbench index references).
|
||||
|
||||
Modifiers are NOT applied here — the caller (routing_execute)
|
||||
handles before_execute/after_execute to avoid double application.
|
||||
"""
|
||||
tool_items = input_args.get("tools")
|
||||
if not isinstance(tool_items, list) or len(tool_items) == 0:
|
||||
# Fallback: send to backend as-is (no modifiers — caller handles them)
|
||||
return self._tool_router_backend_execute(tools_model)(
|
||||
COMPOSIO_MULTI_EXECUTE_TOOL,
|
||||
input_args,
|
||||
)
|
||||
|
||||
parsed = [self._parse_tool_item(item) for item in tool_items]
|
||||
|
||||
# Partition into local (with resolved entry) and remote
|
||||
local_items: t.List[t.Tuple[int, CustomToolsMapEntry]] = []
|
||||
remote_indices: t.List[int] = []
|
||||
for i, p in enumerate(parsed):
|
||||
entry = find_custom_tool(self._custom_tools_map, p["tool_slug"])
|
||||
if entry:
|
||||
local_items.append((i, entry))
|
||||
else:
|
||||
remote_indices.append(i)
|
||||
|
||||
# All remote — just forward entire payload (no modifiers — caller handles them)
|
||||
if not local_items:
|
||||
return self._tool_router_backend_execute(tools_model)(
|
||||
COMPOSIO_MULTI_EXECUTE_TOOL,
|
||||
input_args,
|
||||
)
|
||||
|
||||
ctx = self._session_context
|
||||
assert ctx is not None
|
||||
|
||||
# Determine worker count (capped at MAX_PARALLEL_WORKERS)
|
||||
num_tasks = len(local_items) + (1 if remote_indices else 0)
|
||||
num_workers = min(MAX_PARALLEL_WORKERS, num_tasks)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as pool:
|
||||
# Submit local tool executions
|
||||
local_futures = []
|
||||
for idx, entry in local_items:
|
||||
future = pool.submit(
|
||||
execute_custom_tool,
|
||||
entry,
|
||||
parsed[idx]["arguments"],
|
||||
ctx,
|
||||
)
|
||||
local_futures.append((idx, future))
|
||||
|
||||
# Submit remote batch (single call) if any
|
||||
# No modifiers here — the outer routing_execute handles them
|
||||
remote_future = None
|
||||
if remote_indices:
|
||||
remote_tool_items = [tool_items[i] for i in remote_indices]
|
||||
remote_input = {**input_args, "tools": remote_tool_items}
|
||||
execute_fn = self._tool_router_backend_execute(tools_model)
|
||||
remote_future = pool.submit(
|
||||
execute_fn,
|
||||
COMPOSIO_MULTI_EXECUTE_TOOL,
|
||||
remote_input,
|
||||
)
|
||||
|
||||
# Gather local results
|
||||
local_results: t.List[t.Tuple[int, ToolExecutionResponse]] = []
|
||||
for idx, future in local_futures:
|
||||
local_results.append((idx, future.result()))
|
||||
|
||||
# Gather remote result
|
||||
remote_result: t.Optional[t.Dict[str, t.Any]] = None
|
||||
if remote_future:
|
||||
remote_result = remote_future.result()
|
||||
|
||||
# If only one local tool and no remote, return unwrapped
|
||||
if not remote_indices and len(local_results) == 1:
|
||||
return t.cast(t.Dict[str, t.Any], local_results[0][1])
|
||||
|
||||
# Build local result entries matching backend format
|
||||
local_entries = []
|
||||
for idx, result in local_results:
|
||||
local_entry: t.Dict[str, t.Any] = {
|
||||
"response": {
|
||||
"successful": result["successful"],
|
||||
"data": result["data"],
|
||||
},
|
||||
"tool_slug": parsed[idx]["tool_slug"],
|
||||
}
|
||||
if result.get("error"):
|
||||
local_entry["response"]["error"] = result["error"]
|
||||
local_entry["error"] = result["error"]
|
||||
local_entries.append(local_entry)
|
||||
|
||||
# Merge: remotes first, locals appended (matches TS behavior —
|
||||
# remote results may have workbench index references)
|
||||
remote_data_raw = (remote_result or {}).get("data")
|
||||
remote_data = remote_data_raw if isinstance(remote_data_raw, dict) else {}
|
||||
remote_results_list = (
|
||||
remote_data.get("results", [])
|
||||
if isinstance(remote_data.get("results"), list)
|
||||
else []
|
||||
)
|
||||
all_results = [
|
||||
{**entry, "index": i}
|
||||
for i, entry in enumerate([*remote_results_list, *local_entries])
|
||||
]
|
||||
failed = sum(1 for r in all_results if r.get("error"))
|
||||
merged_data = {**remote_data, "results": all_results}
|
||||
if local_entries and any(
|
||||
key in remote_data
|
||||
for key in ("total_count", "success_count", "error_count")
|
||||
):
|
||||
merged_data["total_count"] = len(all_results)
|
||||
merged_data["success_count"] = len(all_results) - failed
|
||||
merged_data["error_count"] = failed
|
||||
|
||||
remote_error = (
|
||||
str(remote_result.get("error"))
|
||||
if remote_result and remote_result.get("error") is not None
|
||||
else None
|
||||
)
|
||||
has_any_error = any(r.get("error") for _, r in local_results) or bool(
|
||||
remote_error
|
||||
)
|
||||
error_message = None
|
||||
if has_any_error:
|
||||
error_message = (
|
||||
remote_error
|
||||
if remote_error is not None and failed == 0
|
||||
else f"{failed} out of {len(all_results)} tools failed"
|
||||
)
|
||||
|
||||
return {
|
||||
"data": merged_data,
|
||||
"error": error_message,
|
||||
"successful": not has_any_error,
|
||||
}
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
toolkit: str,
|
||||
*,
|
||||
callback_url: t.Optional[str] = None,
|
||||
alias: t.Optional[str] = None,
|
||||
experimental: t.Optional[session_link_params.Experimental] = None,
|
||||
) -> ConnectionRequest:
|
||||
"""
|
||||
Authorize a toolkit for the user and get a connection request.
|
||||
|
||||
Initiates the OAuth flow and returns a ConnectionRequest with redirect URL.
|
||||
|
||||
:param alias: Human-readable alias for the connection. Must be unique
|
||||
per userId and toolkit within the project.
|
||||
:param experimental: Experimental options for this connection. Pass an
|
||||
``Experimental`` dict with ``account_type`` and/or
|
||||
``acl_config_for_shared`` to create a SHARED connection with a
|
||||
per-user ACL. Experimental — shape may change in future releases.
|
||||
"""
|
||||
try:
|
||||
response = self._client.tool_router.session.link(
|
||||
session_id=self.session_id,
|
||||
toolkit=toolkit,
|
||||
callback_url=callback_url if callback_url else omit,
|
||||
alias=alias if alias is not None else omit,
|
||||
experimental=experimental if experimental is not None else omit,
|
||||
)
|
||||
except BadRequestError as error:
|
||||
# The server rejects ACL on PRIVATE connections — surface that
|
||||
# as a typed error mirroring ``composio.connected_accounts.link()``.
|
||||
message = str(error)
|
||||
if ACL_ONLY_FOR_SHARED_ERROR_FRAGMENT in message:
|
||||
raise exceptions.ComposioAclOnlyForSharedError(message) from error
|
||||
raise
|
||||
|
||||
return ConnectionRequest(
|
||||
id=response.connected_account_id,
|
||||
redirect_url=response.redirect_url,
|
||||
status="INITIATED",
|
||||
client=self._client,
|
||||
)
|
||||
|
||||
def toolkits(
|
||||
self,
|
||||
*,
|
||||
toolkits: t.Optional[t.List[str]] = None,
|
||||
next_cursor: t.Optional[str] = None,
|
||||
limit: t.Optional[int] = None,
|
||||
is_connected: t.Optional[bool] = None,
|
||||
search: t.Optional[str] = None,
|
||||
) -> ToolkitConnectionsDetails:
|
||||
"""
|
||||
Get toolkit connection states for the session.
|
||||
"""
|
||||
from composio.core.models.tool_router import (
|
||||
ToolkitConnectedAccount,
|
||||
ToolkitConnection,
|
||||
ToolkitConnectionAuthConfig,
|
||||
ToolkitConnectionsDetails,
|
||||
ToolkitConnectionState,
|
||||
)
|
||||
|
||||
toolkits_params: t.Dict[str, t.Any] = {}
|
||||
if next_cursor is not None:
|
||||
toolkits_params["cursor"] = next_cursor
|
||||
if limit is not None:
|
||||
toolkits_params["limit"] = limit
|
||||
if toolkits is not None:
|
||||
toolkits_params["toolkits"] = toolkits
|
||||
if is_connected is not None:
|
||||
toolkits_params["is_connected"] = is_connected
|
||||
if search is not None:
|
||||
toolkits_params["search"] = search
|
||||
|
||||
result = self._client.tool_router.session.toolkits(
|
||||
session_id=self.session_id,
|
||||
**toolkits_params,
|
||||
)
|
||||
|
||||
toolkit_states: t.List[ToolkitConnectionState] = []
|
||||
for item in result.items:
|
||||
connected_account = item.connected_account
|
||||
auth_config: t.Optional[ToolkitConnectionAuthConfig] = None
|
||||
connected_acc: t.Optional[ToolkitConnectedAccount] = None
|
||||
|
||||
if connected_account:
|
||||
if connected_account.auth_config:
|
||||
auth_config = ToolkitConnectionAuthConfig(
|
||||
id=connected_account.auth_config.id,
|
||||
mode=connected_account.auth_config.auth_scheme,
|
||||
is_composio_managed=connected_account.auth_config.is_composio_managed,
|
||||
)
|
||||
connected_acc = ToolkitConnectedAccount(
|
||||
id=connected_account.id,
|
||||
status=connected_account.status,
|
||||
)
|
||||
|
||||
connection = (
|
||||
None
|
||||
if item.is_no_auth
|
||||
else ToolkitConnection(
|
||||
is_active=(
|
||||
connected_account.status == "ACTIVE"
|
||||
if connected_account
|
||||
else False
|
||||
),
|
||||
auth_config=auth_config,
|
||||
connected_account=connected_acc,
|
||||
)
|
||||
)
|
||||
|
||||
toolkit_state = ToolkitConnectionState(
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
logo=item.meta.logo if item.meta else None,
|
||||
is_no_auth=item.is_no_auth if item.is_no_auth else False,
|
||||
connection=connection,
|
||||
)
|
||||
toolkit_states.append(toolkit_state)
|
||||
|
||||
return ToolkitConnectionsDetails(
|
||||
items=toolkit_states,
|
||||
next_cursor=result.next_cursor,
|
||||
total_pages=int(result.total_pages),
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
model: t.Optional[str] = None,
|
||||
) -> SessionSearchResponse:
|
||||
"""
|
||||
Search for tools by semantic use case.
|
||||
|
||||
Returns relevant tools for the given query with schemas and guidance.
|
||||
"""
|
||||
return self._client.tool_router.session.search(
|
||||
session_id=self.session_id,
|
||||
queries=[{"use_case": query}],
|
||||
model=model if model else omit,
|
||||
experimental=inline_custom_tools_search_experimental(
|
||||
self._inline_custom_tools_payload
|
||||
),
|
||||
)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
tool_slug: str,
|
||||
*,
|
||||
arguments: t.Optional[t.Dict[str, t.Any]] = None,
|
||||
account: t.Optional[str] = None,
|
||||
) -> SessionExecuteResponse:
|
||||
"""
|
||||
Execute a tool within the session.
|
||||
|
||||
For custom tools, accepts the original slug (e.g. "GREP") or the
|
||||
full slug (e.g. "LOCAL_GREP"). Custom tools are executed in-process;
|
||||
remote tools are sent to the Composio backend.
|
||||
|
||||
:param account: Account ID or alias for direct app tool execution in
|
||||
multi-account sessions. Helper/meta tools either ignore this
|
||||
top-level field or define their own account-selection fields.
|
||||
|
||||
Both paths return a ``SessionExecuteResponse`` with ``data``,
|
||||
``error``, and ``log_id`` attributes.
|
||||
"""
|
||||
from composio_client.types.tool_router.session_execute_response import (
|
||||
SessionExecuteResponse,
|
||||
)
|
||||
|
||||
# Check if this is a local tool (by original or final slug)
|
||||
entry = find_custom_tool(self._custom_tools_map, tool_slug)
|
||||
if entry and self._session_context:
|
||||
result = execute_custom_tool(entry, arguments or {}, self._session_context)
|
||||
return SessionExecuteResponse(
|
||||
data=result["data"],
|
||||
error=result["error"],
|
||||
log_id="",
|
||||
)
|
||||
|
||||
return self._client.tool_router.session.execute(
|
||||
session_id=self.session_id,
|
||||
tool_slug=tool_slug,
|
||||
arguments=arguments if arguments is not None else omit,
|
||||
account=account if account is not None else omit,
|
||||
experimental=inline_custom_tools_execute_experimental(
|
||||
self._inline_custom_tools_payload
|
||||
),
|
||||
)
|
||||
|
||||
def custom_tools(
|
||||
self, *, toolkit: t.Optional[str] = None
|
||||
) -> t.List[RegisteredCustomTool]:
|
||||
"""List all custom tools registered in this session.
|
||||
|
||||
Returns tools with their final slugs, schemas, and resolved toolkit.
|
||||
|
||||
:param toolkit: Filter by toolkit slug (e.g. 'gmail', 'DEV_TOOLS')
|
||||
:returns: Array of registered custom tools
|
||||
"""
|
||||
if not self._custom_tools_map:
|
||||
return []
|
||||
|
||||
entries = list(self._custom_tools_map.by_final_slug.values())
|
||||
if toolkit:
|
||||
entries = [
|
||||
e for e in entries if e.toolkit and e.toolkit.lower() == toolkit.lower()
|
||||
]
|
||||
|
||||
return [
|
||||
RegisteredCustomTool(
|
||||
slug=entry.final_slug,
|
||||
name=entry.handle.name,
|
||||
description=entry.handle.description,
|
||||
toolkit=entry.toolkit,
|
||||
input_schema=entry.handle.input_schema,
|
||||
output_schema=entry.handle.output_schema,
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
def custom_toolkits(self) -> t.List[RegisteredCustomToolkit]:
|
||||
"""List all custom toolkits registered in this session.
|
||||
|
||||
Returns toolkits with their tools showing final slugs.
|
||||
"""
|
||||
if not self._custom_tools_map or not self._custom_tools_map.toolkits:
|
||||
return []
|
||||
|
||||
result = []
|
||||
for tk in self._custom_tools_map.toolkits:
|
||||
tools = []
|
||||
for tool in tk.tools:
|
||||
entry = self._custom_tools_map.by_original_slug.get(tool.slug.upper())
|
||||
tools.append(
|
||||
RegisteredCustomTool(
|
||||
slug=entry.final_slug if entry else tool.slug,
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
toolkit=tk.slug,
|
||||
input_schema=tool.input_schema,
|
||||
output_schema=tool.output_schema,
|
||||
)
|
||||
)
|
||||
result.append(
|
||||
RegisteredCustomToolkit(
|
||||
slug=tk.slug,
|
||||
name=tk.name,
|
||||
description=tk.description,
|
||||
tools=tools,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def proxy_execute(
|
||||
self,
|
||||
*,
|
||||
toolkit: str,
|
||||
endpoint: str,
|
||||
method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
body: t.Any = None,
|
||||
parameters: t.Optional[t.List[t.Dict[str, t.Any]]] = None,
|
||||
) -> SessionProxyExecuteResponse:
|
||||
"""Proxy an API call through Composio's auth layer.
|
||||
|
||||
:param toolkit: Composio toolkit slug (e.g. 'gmail', 'github')
|
||||
:param endpoint: API endpoint URL
|
||||
:param method: HTTP method
|
||||
:param body: Request body (for POST, PUT, PATCH)
|
||||
:param parameters: Query/header parameters
|
||||
:returns: Proxied API response
|
||||
"""
|
||||
return proxy_execute_impl(
|
||||
self._client,
|
||||
self.session_id,
|
||||
toolkit=toolkit,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
body=body,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
toolkits: t.Union[session_patch_params.Toolkits, "Omit"] = omit,
|
||||
tools: t.Union[t.Dict[str, session_patch_params.Tools], "Omit"] = omit,
|
||||
tags: t.Union[session_patch_params.Tags, "Omit"] = omit,
|
||||
auth_configs: t.Union[t.Dict[str, str], "Omit"] = omit,
|
||||
connected_accounts: t.Union[
|
||||
t.Optional[t.Dict[str, SequenceNotStr[str]]], "Omit"
|
||||
] = omit,
|
||||
manage_connections: t.Union[
|
||||
t.Optional[session_patch_params.ManageConnections], "Omit"
|
||||
] = omit,
|
||||
sandbox: t.Union[t.Optional[session_patch_params.Workbench], "Omit"] = omit,
|
||||
workbench: t.Union[t.Optional[session_patch_params.Workbench], "Omit"] = omit,
|
||||
multi_account: t.Union[
|
||||
t.Optional[session_patch_params.MultiAccount], "Omit"
|
||||
] = omit,
|
||||
preload: t.Union[session_patch_params.Preload, "Omit"] = omit,
|
||||
) -> None:
|
||||
"""Partially update the session configuration.
|
||||
|
||||
Only the fields provided will be changed; omitted fields are preserved.
|
||||
Mutates this session's ``preload`` in-place.
|
||||
|
||||
Pass ``None`` for ``manage_connections``, ``sandbox``/``workbench``, or
|
||||
``multi_account`` to clear the stored value.
|
||||
|
||||
``workbench`` is a backwards-compatible alias for ``sandbox``. Prefer
|
||||
``sandbox`` in new code.
|
||||
|
||||
All parameters use the same types as the Stainless-generated
|
||||
``client.tool_router.session.patch()`` method.
|
||||
"""
|
||||
from composio.core.models.tool_router import _session_preload_config
|
||||
|
||||
if sandbox is not omit and workbench is not omit:
|
||||
raise exceptions.InvalidParams(
|
||||
"Pass either `sandbox` or `workbench`, not both. "
|
||||
"`workbench` is a backwards-compatible alias for `sandbox`."
|
||||
)
|
||||
|
||||
workbench_payload = sandbox if sandbox is not omit else workbench
|
||||
|
||||
response = self._client.tool_router.session.patch(
|
||||
session_id=self.session_id,
|
||||
toolkits=toolkits,
|
||||
tools=tools,
|
||||
tags=tags,
|
||||
auth_configs=auth_configs,
|
||||
connected_accounts=connected_accounts,
|
||||
manage_connections=manage_connections,
|
||||
workbench=workbench_payload,
|
||||
multi_account=multi_account,
|
||||
preload=preload,
|
||||
)
|
||||
self.preload = _session_preload_config(response.config.preload)
|
||||
|
||||
def delete(self) -> ToolRouterSessionDeleteResponse:
|
||||
"""
|
||||
Delete this session.
|
||||
|
||||
Deleted sessions immediately stop being retrievable or executable. An
|
||||
already-deleted session surfaces the backend 404.
|
||||
"""
|
||||
return delete_tool_router_session(self._client, self.session_id)
|
||||
|
||||
|
||||
class ToolRouterSessionWithMcp(ToolRouterSession[TTool, TToolCollection]):
|
||||
"""A :class:`ToolRouterSession` whose hosted MCP endpoint is exposed.
|
||||
|
||||
Returned by ``create(..., mcp=True)`` / ``use(..., mcp=True)``. The ``mcp``
|
||||
attribute is populated by the base ``__init__`` at runtime; this subclass
|
||||
only surfaces it in the type. See
|
||||
https://docs.composio.dev/docs/sessions-via-mcp
|
||||
"""
|
||||
|
||||
#: Hosted MCP server configuration (url + auth headers) for this session.
|
||||
#: This is the recommended MCP path, gated behind ``mcp=True``.
|
||||
#: See https://docs.composio.dev/docs/sessions-via-mcp
|
||||
mcp: "ToolRouterMCPServerConfig"
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tool router session deletion helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
from urllib.parse import quote
|
||||
|
||||
import typing_extensions as te
|
||||
|
||||
from composio.client import HttpClient
|
||||
from composio.exceptions import ValidationError
|
||||
|
||||
|
||||
class ToolRouterSessionDeleteResponse(te.TypedDict):
|
||||
"""Response returned when a tool router session is deleted."""
|
||||
|
||||
session_id: str
|
||||
deleted: t.Literal[True]
|
||||
|
||||
|
||||
def delete_tool_router_session(
|
||||
client: HttpClient,
|
||||
session_id: str,
|
||||
) -> ToolRouterSessionDeleteResponse:
|
||||
"""Delete a tool router session by ID."""
|
||||
response = client.delete(
|
||||
f"/api/v3.1/tool_router/session/{quote(session_id, safe='')}",
|
||||
cast_to=t.Dict[str, t.Any],
|
||||
)
|
||||
|
||||
response_session_id = response.get("session_id")
|
||||
deleted = response.get("deleted")
|
||||
if not isinstance(response_session_id, str) or deleted is not True:
|
||||
raise ValidationError("Invalid tool router session delete response")
|
||||
|
||||
return {
|
||||
"session_id": response_session_id,
|
||||
"deleted": True,
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Tool router session files mount - list, upload, download, delete files
|
||||
in the session's virtual filesystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import requests
|
||||
from composio_client import omit
|
||||
from composio_client.types.tool_router.session.file_create_download_url_response import (
|
||||
FileCreateDownloadURLResponse,
|
||||
)
|
||||
from composio_client.types.tool_router.session.file_delete_response import (
|
||||
FileDeleteResponse,
|
||||
)
|
||||
from composio_client.types.tool_router.session.file_list_response import (
|
||||
FileListResponse,
|
||||
)
|
||||
|
||||
from composio.client import HttpClient
|
||||
from composio.exceptions import RemoteFileDownloadError, ValidationError
|
||||
from composio.utils.mimetypes import get_extension_from_mime_type
|
||||
from composio.utils.uuid import generate_short_id
|
||||
|
||||
DEFAULT_TOOL_ROUTER_SESSION_FILES_MOUNT_ID = "files"
|
||||
COMPOSIO_DIR = ".composio"
|
||||
TEMP_FILES_DIRECTORY_NAME = "files"
|
||||
|
||||
_MAX_RESPONSE_SIZE = 100 * 1024 * 1024 # 100 MB
|
||||
_CONNECT_TIMEOUT = 5
|
||||
_READ_TIMEOUT = 60
|
||||
|
||||
|
||||
def _is_url(value: str) -> bool:
|
||||
"""Check if a string is a valid HTTP/HTTPS URL."""
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_from_url(url: str) -> t.Tuple[bytes, str]:
|
||||
"""Fetch file content from URL. Returns (content, mimetype)."""
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
stream=True,
|
||||
allow_redirects=False,
|
||||
timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT),
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise ValidationError(f"Failed to fetch file from URL: {e}") from e
|
||||
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
response.close()
|
||||
raise ValidationError(
|
||||
"URL returned redirect. Please provide a direct URL to the file."
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
response.close()
|
||||
raise ValidationError(
|
||||
f"Failed to fetch file from URL: {response.status_code} {response.reason}"
|
||||
)
|
||||
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length and int(content_length) > _MAX_RESPONSE_SIZE:
|
||||
response.close()
|
||||
raise ValidationError(
|
||||
f"File size ({int(content_length)} bytes) exceeds maximum allowed "
|
||||
f"size ({_MAX_RESPONSE_SIZE} bytes)"
|
||||
)
|
||||
|
||||
chunks: t.List[bytes] = []
|
||||
total_bytes = 0
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
total_bytes += len(chunk)
|
||||
if total_bytes > _MAX_RESPONSE_SIZE:
|
||||
response.close()
|
||||
raise ValidationError("Response size exceeds maximum allowed size")
|
||||
chunks.append(chunk)
|
||||
response.close()
|
||||
|
||||
mimetype = response.headers.get("content-type", "application/octet-stream")
|
||||
mimetype = mimetype.split(";")[0].strip()
|
||||
return b"".join(chunks), mimetype
|
||||
|
||||
|
||||
class RemoteFile:
|
||||
"""Represents a file stored in a tool router session's file mount.
|
||||
|
||||
Provides methods to fetch, save, and work with the file content.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
expires_at: str,
|
||||
mount_relative_path: str,
|
||||
sandbox_mount_prefix: str,
|
||||
download_url: str,
|
||||
) -> None:
|
||||
self.expires_at = expires_at
|
||||
self.mount_relative_path = mount_relative_path
|
||||
self.sandbox_mount_prefix = sandbox_mount_prefix
|
||||
self.download_url = download_url
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
"""Filename extracted from the mount path (e.g. 'report.pdf' from 'output/report.pdf')."""
|
||||
return Path(self.mount_relative_path).name
|
||||
|
||||
def buffer(self) -> bytes:
|
||||
"""Fetch the file content as bytes."""
|
||||
try:
|
||||
response = requests.get(
|
||||
self.download_url,
|
||||
timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT),
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RemoteFileDownloadError(
|
||||
f"Failed to download file: {type(e).__name__}",
|
||||
download_url=self.download_url,
|
||||
mount_relative_path=self.mount_relative_path,
|
||||
filename=self.filename,
|
||||
) from e
|
||||
|
||||
if not response.ok:
|
||||
raise RemoteFileDownloadError(
|
||||
f"Failed to download file: {response.status_code} {response.reason}",
|
||||
status_code=response.status_code,
|
||||
status_text=response.reason,
|
||||
download_url=self.download_url,
|
||||
mount_relative_path=self.mount_relative_path,
|
||||
filename=self.filename,
|
||||
)
|
||||
return response.content
|
||||
|
||||
def text(self) -> str:
|
||||
"""Fetch the file content as UTF-8 text."""
|
||||
return self.buffer().decode("utf-8")
|
||||
|
||||
def save(self, path: t.Optional[t.Union[str, Path]] = None) -> str:
|
||||
"""Download and save the file to the local filesystem.
|
||||
|
||||
Returns the absolute path where the file was saved.
|
||||
If path is omitted, saves to ~/.composio/files/ using the filename.
|
||||
"""
|
||||
content = self.buffer()
|
||||
save_path: Path
|
||||
|
||||
if path is not None:
|
||||
save_path = Path(path)
|
||||
else:
|
||||
# SEC-316 defense-in-depth: `self.filename` is `Path(mount_relative_path).name`,
|
||||
# so directory components are already stripped — but verify the resolved
|
||||
# default-location path stays inside the cache dir to reject residual `..`
|
||||
# cases (e.g. ``mount_relative_path == ".."`` keeps ``filename == ".."``).
|
||||
default_dir = Path.home() / COMPOSIO_DIR / TEMP_FILES_DIRECTORY_NAME
|
||||
save_path = default_dir / self.filename
|
||||
if not save_path.resolve().is_relative_to(default_dir.resolve()):
|
||||
raise ValidationError(
|
||||
f"Path traversal detected: filename {self.filename!r} resolves "
|
||||
"outside the intended output directory."
|
||||
)
|
||||
|
||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
save_path.write_bytes(content)
|
||||
return str(save_path.resolve())
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: FileCreateDownloadURLResponse) -> "RemoteFile":
|
||||
"""Create RemoteFile from API response."""
|
||||
return cls(
|
||||
expires_at=data.expires_at,
|
||||
mount_relative_path=data.mount_relative_path,
|
||||
sandbox_mount_prefix=data.sandbox_mount_prefix,
|
||||
download_url=data.download_url,
|
||||
)
|
||||
|
||||
|
||||
class ToolRouterSessionFilesMount:
|
||||
"""File mount for a tool router session - list, upload, download, delete."""
|
||||
|
||||
def __init__(self, client: HttpClient, session_id: str) -> None:
|
||||
self._client = client
|
||||
self._session_id = session_id
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
path: t.Optional[str] = None,
|
||||
mount_id: str = DEFAULT_TOOL_ROUTER_SESSION_FILES_MOUNT_ID,
|
||||
cursor: t.Optional[str] = None,
|
||||
limit: t.Optional[int] = None,
|
||||
) -> FileListResponse:
|
||||
"""List files and directories at the specified path on the session's file mount.
|
||||
|
||||
Args:
|
||||
path: Directory path to list. Use '/' or omit for root.
|
||||
mount_id: ID of the file mount. Defaults to 'files'.
|
||||
cursor: Pagination cursor from previous response's next_cursor.
|
||||
limit: Max files per page (1-500).
|
||||
|
||||
Returns:
|
||||
FileListResponse with items and next_cursor.
|
||||
"""
|
||||
raw_path = path or ""
|
||||
if raw_path in ("", "/"):
|
||||
mount_relative_prefix_arg: t.Any = omit
|
||||
else:
|
||||
prefix_str = raw_path[1:] if raw_path.startswith("/") else raw_path
|
||||
mount_relative_prefix_arg = prefix_str if prefix_str else omit
|
||||
|
||||
return self._client.tool_router.session.files.list(
|
||||
mount_id,
|
||||
session_id=self._session_id,
|
||||
mount_relative_prefix=mount_relative_prefix_arg,
|
||||
cursor=cursor if cursor else omit,
|
||||
limit=int(limit) if limit is not None else omit,
|
||||
)
|
||||
|
||||
def _normalize_upload_input(
|
||||
self,
|
||||
input: t.Union[str, Path, bytes, bytearray],
|
||||
*,
|
||||
remote_path: t.Optional[str] = None,
|
||||
mimetype: t.Optional[str] = None,
|
||||
) -> t.Tuple[bytes, str, str]:
|
||||
"""Normalize upload input to (content, remote_path, mimetype)."""
|
||||
if isinstance(input, (str, Path)):
|
||||
path_str = str(input)
|
||||
if _is_url(path_str):
|
||||
content, mime = _fetch_from_url(path_str)
|
||||
parsed = urlparse(path_str)
|
||||
pathname = unquote(parsed.path)
|
||||
segments = [s for s in pathname.split("/") if s]
|
||||
filename = segments[-1] if segments else ""
|
||||
if not filename or "." not in filename:
|
||||
ext = get_extension_from_mime_type(mime)
|
||||
filename = f"{generate_short_id()}.{ext}"
|
||||
rpath = remote_path or filename
|
||||
return content, rpath, mime
|
||||
# Local file
|
||||
p = Path(path_str)
|
||||
if not p.exists():
|
||||
raise ValidationError(f"File not found: {p}")
|
||||
if not p.is_file():
|
||||
raise ValidationError(f"Not a file: {p}")
|
||||
content = p.read_bytes()
|
||||
mime = mimetype or "application/octet-stream"
|
||||
rpath = remote_path or p.name
|
||||
return content, rpath, mime
|
||||
|
||||
if isinstance(input, (bytes, bytearray)):
|
||||
buffer = bytes(input)
|
||||
if not mimetype and not remote_path:
|
||||
raise ValidationError(
|
||||
"When passing a buffer, either mimetype or remote_path (filename) "
|
||||
"is required. Example: files.upload(buffer, remote_path='data.json', "
|
||||
"mimetype='application/json')"
|
||||
)
|
||||
mime = mimetype or "application/octet-stream"
|
||||
ext = get_extension_from_mime_type(mime)
|
||||
rpath = remote_path or f"upload-{generate_short_id()}.{ext}"
|
||||
return buffer, rpath, mime
|
||||
|
||||
raise ValidationError(
|
||||
f"Unsupported input type: {type(input)}. "
|
||||
"Use str (path/URL), Path, bytes, or bytearray."
|
||||
)
|
||||
|
||||
def upload(
|
||||
self,
|
||||
input: t.Union[str, Path, bytes, bytearray],
|
||||
*,
|
||||
remote_path: t.Optional[str] = None,
|
||||
mimetype: t.Optional[str] = None,
|
||||
mount_id: str = DEFAULT_TOOL_ROUTER_SESSION_FILES_MOUNT_ID,
|
||||
) -> RemoteFile:
|
||||
"""Upload a file to the session's file mount.
|
||||
|
||||
Accepts a file path (local or URL), or raw bytes."""
|
||||
content, rpath, mime = self._normalize_upload_input(
|
||||
input, remote_path=remote_path, mimetype=mimetype
|
||||
)
|
||||
|
||||
create_resp = self._client.tool_router.session.files.create_upload_url(
|
||||
mount_id,
|
||||
session_id=self._session_id,
|
||||
mount_relative_path=rpath,
|
||||
mimetype=mime,
|
||||
)
|
||||
|
||||
try:
|
||||
upload_resp = requests.put(
|
||||
create_resp.upload_url,
|
||||
data=content,
|
||||
headers={"Content-Type": mime},
|
||||
timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT),
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise ValidationError(f"Failed to upload file: {type(e).__name__}") from e
|
||||
|
||||
if not upload_resp.ok:
|
||||
raise ValidationError(
|
||||
f"Failed to upload file: {upload_resp.status_code} {upload_resp.reason}"
|
||||
)
|
||||
|
||||
download_resp = self._client.tool_router.session.files.create_download_url(
|
||||
mount_id,
|
||||
session_id=self._session_id,
|
||||
mount_relative_path=create_resp.mount_relative_path,
|
||||
)
|
||||
|
||||
return RemoteFile.from_api_response(download_resp)
|
||||
|
||||
def download(
|
||||
self,
|
||||
file_path: str,
|
||||
*,
|
||||
mount_id: str = DEFAULT_TOOL_ROUTER_SESSION_FILES_MOUNT_ID,
|
||||
) -> RemoteFile:
|
||||
"""Download a file from the session's file mount.
|
||||
|
||||
Returns a RemoteFile with download_url, buffer(), text(), save() methods."""
|
||||
resp = self._client.tool_router.session.files.create_download_url(
|
||||
mount_id,
|
||||
session_id=self._session_id,
|
||||
mount_relative_path=file_path,
|
||||
)
|
||||
return RemoteFile.from_api_response(resp)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
remote_path: str,
|
||||
*,
|
||||
mount_id: str = DEFAULT_TOOL_ROUTER_SESSION_FILES_MOUNT_ID,
|
||||
) -> FileDeleteResponse:
|
||||
"""Delete a file or directory at the specified path on the session's file mount."""
|
||||
return self._client.tool_router.session.files.delete(
|
||||
mount_id,
|
||||
session_id=self._session_id,
|
||||
mount_relative_path=remote_path,
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from composio import exceptions
|
||||
from composio.client import HttpClient
|
||||
from composio.client.types import (
|
||||
AuthSchemeL,
|
||||
toolkit_list_params,
|
||||
toolkit_list_response,
|
||||
toolkit_retrieve_response,
|
||||
)
|
||||
from composio.core.models.connected_accounts import ConnectedAccounts
|
||||
from composio.utils.pydantic import none_to_omit
|
||||
|
||||
from .base import Resource
|
||||
|
||||
AuthFieldsT: t.TypeAlias = t.List[
|
||||
toolkit_retrieve_response.AuthConfigDetailFieldsConnectedAccountInitiationRequired
|
||||
| toolkit_retrieve_response.AuthConfigDetailFieldsConnectedAccountInitiationOptional
|
||||
| toolkit_retrieve_response.AuthConfigDetailFieldsAuthConfigCreationRequired
|
||||
| toolkit_retrieve_response.AuthConfigDetailFieldsAuthConfigCreationOptional
|
||||
]
|
||||
|
||||
|
||||
class Toolkits(Resource):
|
||||
"""
|
||||
Toolkits are a collectiono of tools that can be used to perform various tasks.
|
||||
They're conceptualized as a set of tools. Ex: Github toolkit can perform
|
||||
Github actions via its collection of tools. This is a replacement of the
|
||||
`apps` concept in the earlier versions of the SDK.
|
||||
"""
|
||||
|
||||
connected_accounts: ConnectedAccounts
|
||||
|
||||
def __init__(self, client: HttpClient):
|
||||
super().__init__(client)
|
||||
self.connected_accounts = ConnectedAccounts(client)
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
category: t.Optional[str] = None,
|
||||
cursor: t.Optional[str] = None,
|
||||
limit: t.Optional[float] = None,
|
||||
sort_by: t.Optional[t.Literal["usage", "alphabetically"]] = None,
|
||||
managed_by: t.Optional[t.Literal["composio", "all", "project"]] = None,
|
||||
) -> toolkit_list_response.ToolkitListResponse:
|
||||
"""List all toolkits."""
|
||||
return self._client.toolkits.list(
|
||||
category=none_to_omit(category),
|
||||
cursor=none_to_omit(cursor),
|
||||
limit=none_to_omit(limit),
|
||||
managed_by=none_to_omit(managed_by),
|
||||
sort_by=none_to_omit(sort_by),
|
||||
)
|
||||
|
||||
@t.overload
|
||||
def get(self) -> t.List[toolkit_list_response.Item]:
|
||||
"""Get all toolkits."""
|
||||
|
||||
@t.overload
|
||||
def get(self, slug: str) -> toolkit_retrieve_response.ToolkitRetrieveResponse:
|
||||
"""Get a toolkit by slug."""
|
||||
|
||||
@t.overload
|
||||
def get(
|
||||
self,
|
||||
*,
|
||||
query: toolkit_list_params.ToolkitListParams,
|
||||
) -> t.List[toolkit_list_response.Item]:
|
||||
"""Get a list of toolkits by query."""
|
||||
|
||||
def get(
|
||||
self,
|
||||
slug: t.Optional[str] = None,
|
||||
*,
|
||||
query: t.Optional[toolkit_list_params.ToolkitListParams] = None,
|
||||
) -> t.Union[
|
||||
toolkit_retrieve_response.ToolkitRetrieveResponse,
|
||||
t.List[toolkit_list_response.Item],
|
||||
]:
|
||||
if slug is not None:
|
||||
return self._client.toolkits.retrieve(slug=slug)
|
||||
return self._client.toolkits.list(**(query or {})).items
|
||||
|
||||
def list_categories(self):
|
||||
"""List all categories of toolkits."""
|
||||
return self._client.toolkits.retrieve_categories().items
|
||||
|
||||
def _get_auth_config_id(self, toolkit: str) -> str:
|
||||
"""Get the auth config ID for a toolkit."""
|
||||
auth_configs = self._client.auth_configs.list(toolkit_slug=toolkit)
|
||||
if len(auth_configs.items) > 0:
|
||||
(auth_config, *_) = sorted(
|
||||
auth_configs.items,
|
||||
key=lambda x: t.cast(str, x.created_at),
|
||||
reverse=True,
|
||||
)
|
||||
return auth_config.id
|
||||
|
||||
return self._client.auth_configs.create(
|
||||
toolkit={"slug": toolkit},
|
||||
auth_config={
|
||||
"type": "use_composio_managed_auth",
|
||||
"tool_access_config": {
|
||||
"tools_for_connected_account_creation": [],
|
||||
},
|
||||
},
|
||||
).auth_config.id
|
||||
|
||||
def authorize(self, *, user_id: str, toolkit: str):
|
||||
"""
|
||||
Authorize a user to a toolkit
|
||||
|
||||
If auth config is not found, it will be created using composio managed auth.
|
||||
|
||||
:param user_id: The ID of the user to authorize.
|
||||
:param toolkit: The slug of the toolkit to authorize.
|
||||
:return: The connection request.
|
||||
"""
|
||||
return self.connected_accounts.initiate(
|
||||
user_id=user_id,
|
||||
auth_config_id=self._get_auth_config_id(
|
||||
toolkit=toolkit,
|
||||
),
|
||||
)
|
||||
|
||||
def get_connected_account_initiation_fields(
|
||||
self,
|
||||
toolkit: str,
|
||||
auth_scheme: AuthSchemeL,
|
||||
required_only: bool = False,
|
||||
) -> AuthFieldsT:
|
||||
"""
|
||||
Get the required property for a given toolkit and auth scheme.
|
||||
"""
|
||||
details = self._client.toolkits.retrieve(slug=toolkit).auth_config_details or []
|
||||
for auth_detail in details:
|
||||
if auth_detail.mode != auth_scheme:
|
||||
continue
|
||||
|
||||
if required_only:
|
||||
return t.cast(
|
||||
AuthFieldsT,
|
||||
auth_detail.fields.connected_account_initiation.required,
|
||||
)
|
||||
|
||||
return t.cast(
|
||||
AuthFieldsT,
|
||||
auth_detail.fields.connected_account_initiation.required
|
||||
+ auth_detail.fields.connected_account_initiation.optional,
|
||||
)
|
||||
|
||||
raise exceptions.InvalidParams(
|
||||
f"auth config details not found with {toolkit=} and {auth_scheme=}"
|
||||
)
|
||||
|
||||
def get_auth_config_creation_fields(
|
||||
self,
|
||||
toolkit: str,
|
||||
auth_scheme: AuthSchemeL,
|
||||
required_only: bool = False,
|
||||
) -> AuthFieldsT:
|
||||
"""
|
||||
Get the required property for a given toolkit and auth scheme.
|
||||
"""
|
||||
info = self._client.toolkits.retrieve(slug=toolkit)
|
||||
for auth_detail in info.auth_config_details or []:
|
||||
if auth_detail.mode != auth_scheme:
|
||||
continue
|
||||
|
||||
if required_only:
|
||||
return t.cast(
|
||||
AuthFieldsT,
|
||||
auth_detail.fields.auth_config_creation.required,
|
||||
)
|
||||
|
||||
return t.cast(
|
||||
AuthFieldsT,
|
||||
auth_detail.fields.auth_config_creation.required
|
||||
+ auth_detail.fields.auth_config_creation.optional,
|
||||
)
|
||||
|
||||
raise exceptions.InvalidParams(
|
||||
f"auth config details not found with {toolkit=} and {auth_scheme=}"
|
||||
)
|
||||
@@ -0,0 +1,770 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
import typing_extensions as te
|
||||
from pydantic import BaseModel as PydanticBaseModel
|
||||
from composio_client import omit
|
||||
|
||||
from composio.client import HttpClient
|
||||
from composio.client.types import (
|
||||
Tool,
|
||||
tool_execute_params,
|
||||
tool_proxy_params,
|
||||
tool_proxy_response,
|
||||
)
|
||||
from composio.core.models._files import FileHelper
|
||||
from composio.core.models.base import Resource
|
||||
from composio.core.models.custom_tool_types import InlineCustomToolsWirePayload
|
||||
from composio.core.models.inline_custom_tools_payload import (
|
||||
inline_custom_tools_execute_experimental,
|
||||
)
|
||||
from composio.core.provider import TTool, TToolCollection
|
||||
from composio.core.provider.agentic import AgenticProvider, AgenticProviderExecuteFn
|
||||
from composio.core.provider.base import ExecuteToolFn
|
||||
from composio.core.provider.base import BaseProvider
|
||||
from composio.core.provider.none_agentic import NonAgenticProvider
|
||||
from composio.core.types import ToolkitVersionParam
|
||||
from composio.exceptions import InvalidParams, ToolVersionRequiredError
|
||||
from composio.utils.pydantic import none_to_omit
|
||||
from composio.utils.toolkit_version import get_toolkit_version
|
||||
from composio.utils.upload_dir_allowlist import resolve_effective_upload_allowlist
|
||||
|
||||
from ._modifiers import (
|
||||
Modifiers,
|
||||
ToolExecuteParams,
|
||||
after_execute,
|
||||
apply_modifier_by_type,
|
||||
before_execute,
|
||||
before_file_upload,
|
||||
merge_before_file_upload,
|
||||
schema_modifier,
|
||||
)
|
||||
|
||||
TOOL_ROUTER_SESSION_TOOLS_PAGE_LIMIT = 500
|
||||
|
||||
|
||||
def _needs_serialization(obj: t.Any) -> bool:
|
||||
"""Check if an object contains any Pydantic model instances."""
|
||||
if isinstance(obj, PydanticBaseModel):
|
||||
return True
|
||||
if isinstance(obj, dict):
|
||||
return any(_needs_serialization(v) for v in obj.values())
|
||||
if isinstance(obj, list):
|
||||
return any(_needs_serialization(item) for item in obj)
|
||||
return False
|
||||
|
||||
|
||||
def _serialize_value(obj: t.Any) -> t.Any:
|
||||
"""Recursively convert Pydantic models to JSON-safe primitives."""
|
||||
if isinstance(obj, PydanticBaseModel):
|
||||
return obj.model_dump(mode="json")
|
||||
if isinstance(obj, dict):
|
||||
return {k: _serialize_value(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_serialize_value(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _serialize_arguments(arguments: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
|
||||
"""Serialize any Pydantic model instances in tool arguments to JSON-safe dicts.
|
||||
|
||||
Prevents JSON serialization errors when arguments contain complex types
|
||||
(e.g., RootModel from discriminated union schemas). Returns the original
|
||||
dict unchanged when no Pydantic models are present.
|
||||
"""
|
||||
if not _needs_serialization(arguments):
|
||||
return arguments
|
||||
return {k: _serialize_value(v) for k, v in arguments.items()}
|
||||
|
||||
|
||||
class ToolExecutionResponse(te.TypedDict):
|
||||
data: t.Dict
|
||||
error: t.Optional[str]
|
||||
successful: bool
|
||||
|
||||
|
||||
class Tools(Resource, t.Generic[TTool, TToolCollection]):
|
||||
"""
|
||||
Tools class definition
|
||||
|
||||
This class is used to manage tools in the Composio SDK.
|
||||
It provides methods to list, get, and execute tools.
|
||||
|
||||
Generic Parameters:
|
||||
TTool: The individual tool type returned by the provider (e.g., ChatCompletionToolParam for OpenAI).
|
||||
TToolCollection: The collection type returned by get() (e.g., list[ChatCompletionToolParam]).
|
||||
|
||||
The return type of get() is automatically inferred from the provider's generic parameters.
|
||||
This works for both built-in providers (OpenAI, Anthropic, etc.) and custom providers.
|
||||
"""
|
||||
|
||||
provider: BaseProvider[TTool, TToolCollection]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: HttpClient,
|
||||
provider: BaseProvider[TTool, TToolCollection],
|
||||
file_download_dir: t.Optional[str] = None,
|
||||
toolkit_versions: t.Optional[ToolkitVersionParam] = None,
|
||||
dangerously_allow_auto_upload_download_files: bool = False,
|
||||
sensitive_file_upload_protection: bool = True,
|
||||
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the tools resource.
|
||||
|
||||
:param client: The client to use for the tools resource.
|
||||
:param provider: The provider to use for the tools resource.
|
||||
:param file_download_dir: Output directory for downloadable files
|
||||
:param toolkit_versions: The versions of the toolkits to use. Defaults to 'latest' if not provided.
|
||||
:param dangerously_allow_auto_upload_download_files: Opt-in for automatic file upload/download. Defaults to False.
|
||||
:param sensitive_file_upload_protection: When True, block local paths on the built-in sensitive-path denylist before upload.
|
||||
:param file_upload_path_deny_segments: Extra path segment names to merge with the built-in denylist.
|
||||
:param file_upload_dirs: Allowlist of directories from which local files may
|
||||
be auto-uploaded. Only consulted when
|
||||
``dangerously_allow_auto_upload_download_files=True``. See Composio docs
|
||||
for details.
|
||||
"""
|
||||
self._auto_upload_download_files = bool(
|
||||
dangerously_allow_auto_upload_download_files
|
||||
)
|
||||
# Resolve allowlist once, but only if auto-upload is enabled. When it's
|
||||
# disabled there's no auto-upload code path, so we pass ``None`` and
|
||||
# FileHelper won't run the allowlist check either way. Manual APIs (if
|
||||
# ever added in the future) should also pass ``None``.
|
||||
resolved_allowlist: t.Optional[t.List[Path]] = (
|
||||
resolve_effective_upload_allowlist(file_upload_dirs)
|
||||
if self._auto_upload_download_files
|
||||
else None
|
||||
)
|
||||
|
||||
self._client = client
|
||||
self._tool_schemas: t.Dict[str, Tool] = {}
|
||||
self._file_helper = FileHelper(
|
||||
client=self._client,
|
||||
outdir=file_download_dir,
|
||||
sensitive_file_upload_protection=sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=file_upload_path_deny_segments,
|
||||
file_upload_allowlist=resolved_allowlist,
|
||||
)
|
||||
self._toolkit_versions = toolkit_versions
|
||||
|
||||
self.provider = provider
|
||||
|
||||
self.provider.set_execute_tool_fn(
|
||||
t.cast(
|
||||
ExecuteToolFn,
|
||||
functools.partial(
|
||||
self.execute,
|
||||
# Dangerously skip version check for provider controlled tool execution
|
||||
dangerously_skip_version_check=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def get_raw_composio_tool_by_slug(self, slug: str) -> Tool:
|
||||
"""
|
||||
Returns schema for the given tool slug.
|
||||
"""
|
||||
return t.cast(
|
||||
Tool,
|
||||
self._client.tools.retrieve(
|
||||
tool_slug=slug,
|
||||
toolkit_versions=none_to_omit(self._toolkit_versions),
|
||||
),
|
||||
)
|
||||
|
||||
def get_raw_composio_tools(
|
||||
self,
|
||||
tools: t.Optional[list[str]] = None,
|
||||
search: t.Optional[str] = None,
|
||||
toolkits: t.Optional[list[str]] = None,
|
||||
scopes: t.Optional[t.List[str]] = None,
|
||||
limit: t.Optional[int] = None,
|
||||
) -> list[Tool]:
|
||||
"""
|
||||
Get a list of tool schemas based on the provided filters.
|
||||
"""
|
||||
if tools is None and search is None and toolkits is None:
|
||||
raise InvalidParams(
|
||||
"Either `tools`, `search`, or `toolkits` must be provided"
|
||||
)
|
||||
|
||||
tools_list = []
|
||||
if tools is not None:
|
||||
if len(tools):
|
||||
tools_list.extend(
|
||||
self._client.tools.list(
|
||||
tool_slugs=",".join(tools),
|
||||
toolkit_versions=none_to_omit(self._toolkit_versions),
|
||||
).items
|
||||
)
|
||||
|
||||
# Search tools by toolkit slugs and search term
|
||||
if toolkits is not None or search is not None:
|
||||
tools_list.extend(
|
||||
self._client.tools.list(
|
||||
toolkit_slug=none_to_omit(",".join(toolkits) if toolkits else None),
|
||||
search=none_to_omit(search),
|
||||
scopes=scopes,
|
||||
limit=limit,
|
||||
toolkit_versions=none_to_omit(self._toolkit_versions),
|
||||
).items
|
||||
)
|
||||
return tools_list
|
||||
|
||||
def get_raw_tool_router_meta_tools(
|
||||
self,
|
||||
session_id: str,
|
||||
modifiers: t.Optional["Modifiers"] = None,
|
||||
) -> list[Tool]:
|
||||
"""
|
||||
Fetches the tools exposed by a tool router session.
|
||||
|
||||
This method fetches helper/meta tools and any preloaded app tools from the
|
||||
Composio API and transforms them to the expected format. It provides access
|
||||
to the underlying tool data without provider-specific wrapping.
|
||||
|
||||
:param session_id: The session ID to get the meta tools for
|
||||
:param modifiers: Optional modifiers to apply to the tool schemas
|
||||
:return: The list of meta tools
|
||||
|
||||
Example:
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
tools_model = composio.tools
|
||||
|
||||
# Get meta tools for a session
|
||||
meta_tools = tools_model.get_raw_tool_router_meta_tools("session_123")
|
||||
print(meta_tools)
|
||||
|
||||
# Get meta tools with schema modifiers
|
||||
from composio.core.models import schema_modifier
|
||||
|
||||
@schema_modifier
|
||||
def modify_schema(tool: str, toolkit: str, schema):
|
||||
# Customize the schema
|
||||
schema.description = f"Modified: {schema.description}"
|
||||
return schema
|
||||
|
||||
meta_tools = tools_model.get_raw_tool_router_meta_tools(
|
||||
"session_123",
|
||||
modifiers=[modify_schema]
|
||||
)
|
||||
```
|
||||
"""
|
||||
# Fetch session tools from the API
|
||||
tools_list: t.List[Tool] = []
|
||||
cursor: t.Optional[str] = None
|
||||
|
||||
while True:
|
||||
tools_response = self._client.tool_router.session.tools(
|
||||
session_id=session_id,
|
||||
cursor=none_to_omit(cursor),
|
||||
limit=TOOL_ROUTER_SESSION_TOOLS_PAGE_LIMIT,
|
||||
)
|
||||
# Cast to Tool type - session.tools returns compatible Item type from different response schema
|
||||
tools_list.extend(t.cast(Tool, item) for item in tools_response.items)
|
||||
|
||||
cursor = getattr(tools_response, "next_cursor", None)
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
# Apply schema modifiers if provided
|
||||
if modifiers is not None:
|
||||
from composio.core.models._modifiers import apply_modifier_by_type
|
||||
|
||||
tools_list = [
|
||||
t.cast(
|
||||
Tool,
|
||||
apply_modifier_by_type(
|
||||
modifiers=modifiers,
|
||||
toolkit=tool.toolkit.slug,
|
||||
tool=tool.slug,
|
||||
type="schema",
|
||||
schema=tool,
|
||||
),
|
||||
)
|
||||
for tool in tools_list
|
||||
]
|
||||
|
||||
self._tool_schemas.update(
|
||||
{tool.slug: tool.model_copy(deep=True) for tool in tools_list}
|
||||
)
|
||||
|
||||
return tools_list
|
||||
|
||||
def _get(
|
||||
self,
|
||||
user_id: str,
|
||||
tools: t.Optional[list[str]] = None,
|
||||
search: t.Optional[str] = None,
|
||||
toolkits: t.Optional[list[str]] = None,
|
||||
scopes: t.Optional[t.List[str]] = None,
|
||||
modifiers: t.Optional[Modifiers] = None,
|
||||
limit: t.Optional[int] = None,
|
||||
) -> TToolCollection:
|
||||
"""Get a list of tools based on the provided filters."""
|
||||
tools_list = self.get_raw_composio_tools(
|
||||
tools=tools,
|
||||
search=search,
|
||||
toolkits=toolkits,
|
||||
scopes=scopes,
|
||||
limit=limit,
|
||||
)
|
||||
if modifiers is not None:
|
||||
tools_list = [
|
||||
apply_modifier_by_type(
|
||||
modifiers=modifiers,
|
||||
toolkit=tool.toolkit.slug,
|
||||
tool=tool.slug,
|
||||
type="schema",
|
||||
schema=tool,
|
||||
)
|
||||
for tool in tools_list
|
||||
]
|
||||
|
||||
self._tool_schemas.update(
|
||||
{tool.slug: tool.model_copy(deep=True) for tool in tools_list}
|
||||
)
|
||||
|
||||
# Always enhance schema descriptions (type hints and required notes)
|
||||
# regardless of dangerously_allow_auto_upload_download_files
|
||||
for tool in tools_list:
|
||||
tool.input_parameters = self._file_helper.enhance_schema_descriptions(
|
||||
schema=tool.input_parameters,
|
||||
)
|
||||
|
||||
# Only process file_uploadable schemas when the caller opted in via
|
||||
# `dangerously_allow_auto_upload_download_files=True`.
|
||||
if self._auto_upload_download_files:
|
||||
for tool in tools_list:
|
||||
tool.input_parameters = (
|
||||
self._file_helper.process_file_uploadable_schema(
|
||||
schema=tool.input_parameters,
|
||||
)
|
||||
)
|
||||
|
||||
if issubclass(type(self.provider), NonAgenticProvider):
|
||||
return t.cast(
|
||||
TToolCollection,
|
||||
t.cast(
|
||||
NonAgenticProvider[TTool, TToolCollection], self.provider
|
||||
).wrap_tools(tools=tools_list),
|
||||
)
|
||||
|
||||
return t.cast(
|
||||
TToolCollection,
|
||||
t.cast(AgenticProvider[TTool, TToolCollection], self.provider).wrap_tools(
|
||||
tools=tools_list,
|
||||
execute_tool=self._wrap_execute_tool(
|
||||
user_id=user_id,
|
||||
modifiers=modifiers,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def get(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
slug: t.Optional[str] = None,
|
||||
tools: t.Optional[list[str]] = None,
|
||||
search: t.Optional[str] = None,
|
||||
toolkits: t.Optional[list[str]] = None,
|
||||
scopes: t.Optional[t.List[str]] = None,
|
||||
modifiers: t.Optional[Modifiers] = None,
|
||||
limit: t.Optional[int] = None,
|
||||
) -> TToolCollection:
|
||||
"""
|
||||
Get a tool or list of tools based on the provided arguments.
|
||||
|
||||
The return type is automatically inferred based on the provider's generic parameters.
|
||||
For example:
|
||||
- OpenAIProvider -> list[ChatCompletionToolParam]
|
||||
- AnthropicProvider -> list[ToolParam]
|
||||
- CustomProvider[MyTool, list[MyTool]] -> list[MyTool]
|
||||
|
||||
:param user_id: The user ID to get tools for.
|
||||
:param slug: Get a single tool by slug.
|
||||
:param tools: Get tools by a list of tool slugs.
|
||||
:param search: Search tools by search term.
|
||||
:param toolkits: Get tools from specific toolkits.
|
||||
:param scopes: Filter by scopes.
|
||||
:param modifiers: Optional modifiers to apply.
|
||||
:param limit: Limit the number of tools returned.
|
||||
:return: Provider-specific tool collection (TToolCollection).
|
||||
"""
|
||||
if slug is not None:
|
||||
return self._get(
|
||||
user_id=user_id,
|
||||
tools=[slug],
|
||||
modifiers=modifiers,
|
||||
)
|
||||
return self._get(
|
||||
user_id=user_id,
|
||||
tools=tools,
|
||||
search=search,
|
||||
toolkits=toolkits,
|
||||
scopes=scopes,
|
||||
modifiers=modifiers,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def _wrap_execute_tool(
|
||||
self,
|
||||
modifiers: t.Optional[Modifiers] = None,
|
||||
user_id: t.Optional[str] = None,
|
||||
) -> AgenticProviderExecuteFn:
|
||||
"""Wrap the execute tool function"""
|
||||
return t.cast(
|
||||
AgenticProviderExecuteFn,
|
||||
functools.partial(
|
||||
self.execute,
|
||||
modifiers=modifiers,
|
||||
user_id=user_id,
|
||||
# Dangerously skip version check for agentic tool execution via providers
|
||||
# This can be safe because most agentic flows users fetch latest version and then execute the tool
|
||||
dangerously_skip_version_check=True,
|
||||
),
|
||||
)
|
||||
|
||||
def _wrap_execute_tool_for_tool_router(
|
||||
self,
|
||||
session_id: str,
|
||||
modifiers: t.Optional[Modifiers] = None,
|
||||
inline_custom_tools_payload: t.Optional[InlineCustomToolsWirePayload] = None,
|
||||
) -> AgenticProviderExecuteFn:
|
||||
"""
|
||||
Create an execute function for tool router session tools.
|
||||
|
||||
This method creates a function that executes tools within a tool router session context.
|
||||
It uses the session's execute endpoint which handles authentication and connection
|
||||
management automatically.
|
||||
|
||||
:param session_id: The session ID
|
||||
:param modifiers: Optional modifiers to apply before and after execution
|
||||
:return: Execute function for tool router
|
||||
"""
|
||||
|
||||
def execute_tool_fn(slug: str, arguments: t.Dict) -> t.Dict:
|
||||
"""
|
||||
Execute a tool in the tool router session.
|
||||
|
||||
This function is used by agentic providers to execute tools within
|
||||
a tool router session context. It uses the session's execute
|
||||
endpoint which handles authentication and connection management
|
||||
automatically.
|
||||
|
||||
:param slug: The tool slug to execute
|
||||
:param arguments: The tool arguments
|
||||
:return: Tool execution response
|
||||
"""
|
||||
tool = self._tool_schemas.get(slug)
|
||||
|
||||
if tool is None:
|
||||
tool = t.cast(
|
||||
Tool,
|
||||
self._client.tools.retrieve(
|
||||
tool_slug=slug,
|
||||
toolkit_versions=none_to_omit(self._toolkit_versions),
|
||||
),
|
||||
)
|
||||
self._tool_schemas[slug] = tool
|
||||
|
||||
if self._auto_upload_download_files:
|
||||
meta_tk = tool.toolkit.slug if tool.toolkit else "composio"
|
||||
bfu = merge_before_file_upload(
|
||||
modifiers,
|
||||
tool=slug,
|
||||
toolkit=meta_tk,
|
||||
)
|
||||
arguments = self._file_helper.substitute_file_uploads(
|
||||
tool=tool,
|
||||
request=arguments,
|
||||
before_file_upload=bfu,
|
||||
)
|
||||
|
||||
toolkit_slug = tool.toolkit.slug if tool.toolkit else "composio"
|
||||
|
||||
# Apply before_execute modifiers
|
||||
processed_arguments = arguments
|
||||
if modifiers is not None:
|
||||
params: ToolExecuteParams = {
|
||||
"arguments": arguments,
|
||||
}
|
||||
type_before: t.Literal["before_execute"] = "before_execute"
|
||||
modified_params = apply_modifier_by_type(
|
||||
modifiers=modifiers,
|
||||
toolkit=toolkit_slug,
|
||||
tool=slug,
|
||||
type=type_before,
|
||||
request=params,
|
||||
)
|
||||
processed_arguments = modified_params.get("arguments", arguments)
|
||||
|
||||
# Serialize any Pydantic model instances before sending to the API
|
||||
processed_arguments = _serialize_arguments(processed_arguments)
|
||||
|
||||
response = self._client.tool_router.session.execute(
|
||||
session_id=session_id,
|
||||
tool_slug=slug,
|
||||
arguments=processed_arguments,
|
||||
# Provider-wrapped session tools are agentic calls, so they opt into
|
||||
# direct tool offload when the backend session workbench allows it.
|
||||
enable_auto_workbench_offload=True,
|
||||
experimental=inline_custom_tools_execute_experimental(
|
||||
inline_custom_tools_payload
|
||||
),
|
||||
)
|
||||
|
||||
# Convert response to standard format
|
||||
result: ToolExecutionResponse = {
|
||||
"data": response.data if hasattr(response, "data") else {},
|
||||
"error": response.error if hasattr(response, "error") else None,
|
||||
"successful": not (hasattr(response, "error") and response.error),
|
||||
}
|
||||
|
||||
# Apply after_execute modifiers
|
||||
if modifiers is not None:
|
||||
type_after: t.Literal["after_execute"] = "after_execute"
|
||||
result = apply_modifier_by_type(
|
||||
modifiers=modifiers,
|
||||
toolkit=toolkit_slug,
|
||||
tool=slug,
|
||||
type=type_after,
|
||||
response=result,
|
||||
)
|
||||
|
||||
return t.cast(t.Dict, result)
|
||||
|
||||
return t.cast(AgenticProviderExecuteFn, execute_tool_fn)
|
||||
|
||||
def _execute_tool(
|
||||
self,
|
||||
slug: str,
|
||||
arguments: t.Dict,
|
||||
connected_account_id: t.Optional[str] = None,
|
||||
custom_auth_params: t.Optional[tool_execute_params.CustomAuthParams] = None,
|
||||
custom_connection_data: t.Optional[
|
||||
tool_execute_params.CustomConnectionData
|
||||
] = None,
|
||||
user_id: t.Optional[str] = None,
|
||||
text: t.Optional[str] = None,
|
||||
version: t.Optional[str] = None,
|
||||
dangerously_skip_version_check: t.Optional[bool] = None,
|
||||
) -> ToolExecutionResponse:
|
||||
"""Execute a tool"""
|
||||
# Serialize any Pydantic model instances in arguments to plain dicts
|
||||
# before sending to the API (fixes PLEN-1514: RootModel not JSON serializable)
|
||||
arguments = _serialize_arguments(arguments)
|
||||
|
||||
# Get the tool to determine its toolkit
|
||||
tool = self.get_raw_composio_tool_by_slug(slug)
|
||||
|
||||
# If version is not explicitly provided, resolve it from instance-level toolkit versions
|
||||
# This matches the TypeScript behavior - always resolve version when None
|
||||
if version is None:
|
||||
toolkit_slug = tool.toolkit.slug if tool.toolkit else "unknown"
|
||||
# Use instance-level toolkit versions configuration
|
||||
version = get_toolkit_version(toolkit_slug, self._toolkit_versions)
|
||||
|
||||
# Check if the version is 'latest' and dangerously_skip_version_check is not True
|
||||
# If so, raise an error to prevent unexpected behavior
|
||||
if version == "latest" and not dangerously_skip_version_check:
|
||||
raise ToolVersionRequiredError()
|
||||
|
||||
return t.cast(
|
||||
ToolExecutionResponse,
|
||||
# Disable retries: tool execution is a non-idempotent write, and a
|
||||
# silent retry after a read timeout can duplicate the side effect.
|
||||
self._client.without_retries.tools.execute(
|
||||
tool_slug=slug,
|
||||
arguments=arguments,
|
||||
connected_account_id=none_to_omit(connected_account_id),
|
||||
custom_auth_params=none_to_omit(custom_auth_params),
|
||||
custom_connection_data=none_to_omit(custom_connection_data),
|
||||
user_id=none_to_omit(user_id),
|
||||
text=none_to_omit(text),
|
||||
version=none_to_omit(version),
|
||||
).model_dump(
|
||||
exclude={
|
||||
"log_id",
|
||||
"session_info",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
slug: str,
|
||||
arguments: t.Dict,
|
||||
*,
|
||||
connected_account_id: t.Optional[str] = None,
|
||||
custom_auth_params: t.Optional[tool_execute_params.CustomAuthParams] = None,
|
||||
custom_connection_data: t.Optional[
|
||||
tool_execute_params.CustomConnectionData
|
||||
] = None,
|
||||
user_id: t.Optional[str] = None,
|
||||
text: t.Optional[str] = None,
|
||||
version: t.Optional[str] = None,
|
||||
dangerously_skip_version_check: t.Optional[bool] = None,
|
||||
modifiers: t.Optional[Modifiers] = None,
|
||||
) -> ToolExecutionResponse:
|
||||
"""
|
||||
Execute a tool with the provided parameters.
|
||||
|
||||
This method calls the Composio API to execute the tool and returns the response.
|
||||
|
||||
:param slug: The slug of the tool to execute.
|
||||
:param arguments: The arguments to pass to the tool.
|
||||
:param connected_account_id: The ID of the connected account to use for the tool.
|
||||
:param custom_auth_params: The custom auth params to use for the tool.
|
||||
:param custom_connection_data: The custom connection data to use for the tool, takes priority over custom_auth_params.
|
||||
:param user_id: The ID of the user to execute the tool for.
|
||||
:param text: The text to pass to the tool.
|
||||
:param version: The version of the tool to execute (overrides the SDK-level toolkit versions for this execution).
|
||||
:param dangerously_skip_version_check: Skip the version check for 'latest' version. This might cause unexpected behavior when new versions are released.
|
||||
:param modifiers: The modifiers to apply to the tool (include ``@before_file_upload`` for file path hooks).
|
||||
:return: The response from the tool.
|
||||
"""
|
||||
|
||||
tool = self._tool_schemas.get(slug)
|
||||
|
||||
if tool is None:
|
||||
tool = t.cast(
|
||||
Tool,
|
||||
self._client.tools.retrieve(
|
||||
tool_slug=slug,
|
||||
toolkit_versions=none_to_omit(self._toolkit_versions),
|
||||
),
|
||||
)
|
||||
self._tool_schemas[slug] = tool
|
||||
|
||||
if self._auto_upload_download_files:
|
||||
tk = tool.toolkit.slug if tool.toolkit else "unknown"
|
||||
bfu = merge_before_file_upload(
|
||||
modifiers,
|
||||
tool=slug,
|
||||
toolkit=tk,
|
||||
)
|
||||
arguments = self._file_helper.substitute_file_uploads(
|
||||
tool=tool,
|
||||
request=arguments,
|
||||
before_file_upload=bfu,
|
||||
)
|
||||
|
||||
if modifiers is not None:
|
||||
type_before_exec: t.Literal["before_execute"] = "before_execute"
|
||||
request_params: ToolExecuteParams = {
|
||||
"arguments": arguments,
|
||||
}
|
||||
if connected_account_id is not None:
|
||||
request_params["connected_account_id"] = connected_account_id
|
||||
if custom_auth_params is not None:
|
||||
request_params["custom_auth_params"] = custom_auth_params
|
||||
if custom_connection_data is not None:
|
||||
request_params["custom_connection_data"] = custom_connection_data
|
||||
if version is not None:
|
||||
request_params["version"] = version
|
||||
if text is not None:
|
||||
request_params["text"] = text
|
||||
if user_id is not None:
|
||||
request_params["user_id"] = user_id
|
||||
if dangerously_skip_version_check is not None:
|
||||
request_params["dangerously_skip_version_check"] = (
|
||||
dangerously_skip_version_check
|
||||
)
|
||||
processed_params = apply_modifier_by_type(
|
||||
modifiers=modifiers,
|
||||
toolkit=tool.toolkit.slug,
|
||||
tool=slug,
|
||||
type=type_before_exec,
|
||||
request=request_params,
|
||||
)
|
||||
connected_account_id = processed_params.get(
|
||||
"connected_account_id", connected_account_id
|
||||
)
|
||||
custom_auth_params = processed_params.get(
|
||||
"custom_auth_params", custom_auth_params
|
||||
)
|
||||
custom_connection_data = processed_params.get(
|
||||
"custom_connection_data", custom_connection_data
|
||||
)
|
||||
text = processed_params.get("text", text)
|
||||
version = processed_params.get("version", version)
|
||||
user_id = processed_params.get("user_id", user_id)
|
||||
arguments = processed_params["arguments"]
|
||||
dangerously_skip_version_check = processed_params.get(
|
||||
"dangerously_skip_version_check", dangerously_skip_version_check
|
||||
)
|
||||
response = self._execute_tool(
|
||||
slug=slug,
|
||||
arguments=arguments,
|
||||
connected_account_id=connected_account_id,
|
||||
custom_auth_params=custom_auth_params,
|
||||
custom_connection_data=custom_connection_data,
|
||||
user_id=user_id,
|
||||
text=text,
|
||||
version=version,
|
||||
dangerously_skip_version_check=dangerously_skip_version_check,
|
||||
)
|
||||
if self._auto_upload_download_files:
|
||||
response = self._file_helper.substitute_file_downloads(
|
||||
tool=tool,
|
||||
response=response,
|
||||
)
|
||||
if modifiers is not None:
|
||||
response = apply_modifier_by_type(
|
||||
modifiers=modifiers,
|
||||
toolkit=tool.toolkit.slug,
|
||||
tool=slug,
|
||||
type="after_execute",
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
def proxy(
|
||||
self,
|
||||
endpoint: str,
|
||||
method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"],
|
||||
body: t.Optional[object] = None,
|
||||
connected_account_id: t.Optional[str] = None,
|
||||
parameters: t.Optional[t.List[tool_proxy_params.Parameter]] = None,
|
||||
custom_connection_data: t.Optional[
|
||||
tool_proxy_params.CustomConnectionData
|
||||
] = None,
|
||||
) -> tool_proxy_response.ToolProxyResponse:
|
||||
"""Proxy a tool call to the Composio API"""
|
||||
# Disable retries: a proxied call is a non-idempotent write, and a silent
|
||||
# retry after a read timeout can duplicate the side effect.
|
||||
return self._client.without_retries.tools.proxy(
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
body=body if body is not None else omit,
|
||||
connected_account_id=connected_account_id
|
||||
if connected_account_id is not None
|
||||
else omit,
|
||||
parameters=parameters if parameters is not None else omit,
|
||||
custom_connection_data=custom_connection_data
|
||||
if custom_connection_data is not None
|
||||
else omit,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Tools",
|
||||
"ToolExecuteParams",
|
||||
"ToolExecutionResponse",
|
||||
"Modifiers",
|
||||
"after_execute",
|
||||
"before_execute",
|
||||
"before_file_upload",
|
||||
"schema_modifier",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Webhook event types for typed event handling.
|
||||
|
||||
This module provides TypedDict definitions for specific webhook event types,
|
||||
enabling type-safe handling of events like connection expiration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
from enum import Enum
|
||||
|
||||
import typing_extensions as te
|
||||
|
||||
|
||||
class WebhookEventType(str, Enum):
|
||||
"""Known webhook event types."""
|
||||
|
||||
CONNECTION_EXPIRED = "composio.connected_account.expired"
|
||||
TRIGGER_MESSAGE = "composio.trigger.message"
|
||||
|
||||
|
||||
class ConnectionStatusEnum(str, Enum):
|
||||
"""Connection status values."""
|
||||
|
||||
INITIALIZING = "INITIALIZING"
|
||||
INITIATED = "INITIATED"
|
||||
ACTIVE = "ACTIVE"
|
||||
FAILED = "FAILED"
|
||||
EXPIRED = "EXPIRED"
|
||||
INACTIVE = "INACTIVE"
|
||||
REVOKED = "REVOKED"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONNECTED ACCOUNT DATA (matches GET /api/v3/connected_accounts/{id})
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ConnectedAccountToolkit(te.TypedDict):
|
||||
"""Toolkit information in connected account response."""
|
||||
|
||||
slug: str
|
||||
|
||||
|
||||
class ConnectedAccountAuthConfigDeprecated(te.TypedDict, total=False):
|
||||
"""Deprecated auth config fields."""
|
||||
|
||||
uuid: str
|
||||
|
||||
|
||||
class ConnectedAccountAuthConfig(te.TypedDict, total=False):
|
||||
"""Auth config details in connected account response."""
|
||||
|
||||
id: te.Required[str]
|
||||
auth_scheme: te.Required[str]
|
||||
is_composio_managed: te.Required[bool]
|
||||
is_disabled: te.Required[bool]
|
||||
deprecated: ConnectedAccountAuthConfigDeprecated
|
||||
|
||||
|
||||
class ConnectionStateVal(te.TypedDict, total=False):
|
||||
"""Connection state value - varies by auth scheme."""
|
||||
|
||||
status: str
|
||||
# OAuth2 fields
|
||||
access_token: str
|
||||
refresh_token: t.Optional[str]
|
||||
token_type: str
|
||||
expires_in: t.Union[int, str, None]
|
||||
scope: t.Union[str, t.List[str], None]
|
||||
id_token: str
|
||||
code_verifier: str
|
||||
callback_url: str
|
||||
# OAuth1 fields
|
||||
oauth_token: str
|
||||
oauth_token_secret: str
|
||||
# API Key fields
|
||||
api_key: str
|
||||
generic_api_key: str
|
||||
# Bearer Token fields
|
||||
token: str
|
||||
# Basic auth fields
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class ConnectionState(te.TypedDict):
|
||||
"""Connection state data discriminated by auth scheme."""
|
||||
|
||||
authScheme: str
|
||||
val: ConnectionStateVal
|
||||
|
||||
|
||||
class ConnectedAccountDeprecated(te.TypedDict, total=False):
|
||||
"""Deprecated connected account fields."""
|
||||
|
||||
labels: t.List[str]
|
||||
uuid: str
|
||||
|
||||
|
||||
class SingleConnectedAccountDetailedResponse(te.TypedDict, total=False):
|
||||
"""
|
||||
Connected account data matching GET /api/v3/connected_accounts/{id} response.
|
||||
|
||||
This is used in webhook payloads for connection lifecycle events.
|
||||
It intentionally does NOT reuse composio_client's ConnectedAccountRetrieveResponse
|
||||
because webhook payloads arrive in raw snake_case format, while the SDK client
|
||||
transforms responses to a different shape. This TypedDict validates the raw
|
||||
webhook payload directly without any transformation layer.
|
||||
|
||||
See Also:
|
||||
composio_client.types.connected_account_retrieve_response for the SDK version.
|
||||
"""
|
||||
|
||||
toolkit: te.Required[ConnectedAccountToolkit]
|
||||
auth_config: te.Required[ConnectedAccountAuthConfig]
|
||||
id: te.Required[str]
|
||||
user_id: te.Required[str]
|
||||
status: te.Required[str] # ConnectionStatusEnum value
|
||||
created_at: te.Required[str]
|
||||
updated_at: te.Required[str]
|
||||
state: te.Required[ConnectionState]
|
||||
data: te.Required[t.Dict[str, t.Any]]
|
||||
params: te.Required[t.Dict[str, t.Any]]
|
||||
status_reason: te.Required[t.Optional[str]]
|
||||
is_disabled: te.Required[bool]
|
||||
test_request_endpoint: str
|
||||
deprecated: ConnectedAccountDeprecated
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONNECTION EXPIRED WEBHOOK EVENT
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class WebhookConnectionMetadata(te.TypedDict):
|
||||
"""Webhook metadata for connection events."""
|
||||
|
||||
project_id: str
|
||||
org_id: str
|
||||
|
||||
|
||||
class ConnectionExpiredEvent(te.TypedDict):
|
||||
"""
|
||||
Connection expired webhook event payload.
|
||||
|
||||
Emitted when a connected account expires due to authentication refresh failure.
|
||||
|
||||
Example:
|
||||
>>> from composio.core.models.webhook_events import is_connection_expired_event
|
||||
>>>
|
||||
>>> def handle_webhook(payload: dict) -> None:
|
||||
... if is_connection_expired_event(payload):
|
||||
... print(f"Connection {payload['data']['id']} expired")
|
||||
... print(f"Toolkit: {payload['data']['toolkit']['slug']}")
|
||||
... print(f"User: {payload['data']['user_id']}")
|
||||
"""
|
||||
|
||||
id: str # Unique message ID (e.g., "msg_847cdfcd-d219-4f18-a6dd-91acd42ca94a")
|
||||
timestamp: str # ISO-8601 timestamp
|
||||
type: t.Literal["composio.connected_account.expired"]
|
||||
data: SingleConnectedAccountDetailedResponse
|
||||
metadata: WebhookConnectionMetadata
|
||||
|
||||
|
||||
# Type alias for non-trigger webhook events with specific typed schemas.
|
||||
# Trigger events (composio.trigger.message) are handled through the
|
||||
# TriggerEvent type in triggers.py via the trigger subscription system.
|
||||
WebhookEvent = t.Union[ConnectionExpiredEvent]
|
||||
|
||||
|
||||
def is_connection_expired_event(
|
||||
payload: t.Dict[str, t.Any],
|
||||
) -> t.TypeGuard[ConnectionExpiredEvent]:
|
||||
"""
|
||||
Check if a webhook payload is a connection expired event.
|
||||
|
||||
:param payload: The webhook payload to check
|
||||
:return: True if this is a connection expired event
|
||||
|
||||
Example:
|
||||
>>> from composio.core.models.webhook_events import is_connection_expired_event
|
||||
>>>
|
||||
>>> if is_connection_expired_event(payload):
|
||||
... handle_connection_expired(payload) # payload is narrowed to ConnectionExpiredEvent
|
||||
"""
|
||||
return (
|
||||
isinstance(payload, dict)
|
||||
and payload.get("type") == WebhookEventType.CONNECTION_EXPIRED.value
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .agentic import AgenticProvider, AgenticProviderExecuteFn
|
||||
from .base import TTool, TToolCollection
|
||||
from .none_agentic import NonAgenticProvider
|
||||
|
||||
__all__ = [
|
||||
"TTool",
|
||||
"TToolCollection",
|
||||
"AgenticProvider",
|
||||
"NonAgenticProvider",
|
||||
"AgenticProviderExecuteFn",
|
||||
]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
OpenAI provider implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import typing as t
|
||||
|
||||
from openai import Client
|
||||
from openai.types.beta.thread import Thread
|
||||
from openai.types.beta.threads.run import Run
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from openai.types.chat.chat_completion_message_tool_call import (
|
||||
ChatCompletionMessageToolCall,
|
||||
)
|
||||
from openai.types.chat.chat_completion_tool_param import ChatCompletionToolParam
|
||||
from openai.types.shared_params.function_definition import FunctionDefinition
|
||||
from openai.types.shared_params.function_parameters import FunctionParameters
|
||||
|
||||
from composio.core.provider import NonAgenticProvider
|
||||
from composio.types import Modifiers, Tool, ToolExecutionResponse
|
||||
from composio.utils.shared import normalize_tool_arguments
|
||||
|
||||
OpenAITool: t.TypeAlias = ChatCompletionToolParam
|
||||
OpenAIToolCollection: t.TypeAlias = t.List[OpenAITool]
|
||||
|
||||
|
||||
class OpenAIProvider(
|
||||
NonAgenticProvider[OpenAITool, OpenAIToolCollection], name="openai"
|
||||
):
|
||||
"""OpenAIProvider class definition"""
|
||||
|
||||
def wrap_tool(self, tool: Tool) -> OpenAITool:
|
||||
return ChatCompletionToolParam(
|
||||
function=FunctionDefinition(
|
||||
name=tool.slug,
|
||||
description=tool.description,
|
||||
parameters=t.cast(FunctionParameters, tool.input_parameters),
|
||||
strict=None,
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
|
||||
def wrap_tools(self, tools: t.Sequence[Tool]) -> OpenAIToolCollection:
|
||||
return [self.wrap_tool(tool) for tool in tools]
|
||||
|
||||
def execute_tool_call(
|
||||
self,
|
||||
user_id: str,
|
||||
tool_call: ChatCompletionMessageToolCall,
|
||||
modifiers: t.Optional[Modifiers] = None,
|
||||
) -> ToolExecutionResponse:
|
||||
"""Execute a tool call.
|
||||
|
||||
:param tool_call: Tool call metadata.
|
||||
:param user_id: User ID to use for executing the function call.
|
||||
:return: Object containing output data from the tool call.
|
||||
"""
|
||||
# OpenAI always serializes tool arguments as a JSON string; normalize
|
||||
# tolerates empty / object-shaped payloads too (issue #2406).
|
||||
return self.execute_tool(
|
||||
slug=tool_call.function.name,
|
||||
arguments=normalize_tool_arguments(tool_call.function.arguments),
|
||||
modifiers=modifiers,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
def handle_tool_calls(
|
||||
self,
|
||||
user_id: str,
|
||||
response: ChatCompletion,
|
||||
modifiers: t.Optional[Modifiers] = None,
|
||||
) -> t.List[ToolExecutionResponse]:
|
||||
"""
|
||||
Handle tool calls from OpenAI chat completion object.
|
||||
|
||||
:param response: Chat completion object from
|
||||
openai.OpenAI.chat.completions.create function call
|
||||
:param user_id: User ID to use for executing the function call.
|
||||
:return: A list of output objects from the function calls.
|
||||
"""
|
||||
outputs = []
|
||||
# Only the first choice is actionable: its tool results feed back into a
|
||||
# single assistant turn. With n > 1, iterating every choice would run each
|
||||
# tool call once per choice and orphan the tool_call_ids belonging to the
|
||||
# alternative completions.
|
||||
choice = response.choices[0] if response.choices else None
|
||||
# A single assistant message can carry several tool calls (parallel tool
|
||||
# calls, on by default); each one needs its own tool result.
|
||||
if choice is not None and choice.message.tool_calls is not None:
|
||||
for tool_call in choice.message.tool_calls:
|
||||
outputs.append(
|
||||
self.execute_tool_call(
|
||||
user_id=user_id,
|
||||
tool_call=t.cast(ChatCompletionMessageToolCall, tool_call),
|
||||
modifiers=modifiers,
|
||||
)
|
||||
)
|
||||
return outputs
|
||||
|
||||
def handle_assistant_tool_calls(
|
||||
self,
|
||||
user_id: str,
|
||||
run: Run,
|
||||
) -> t.List:
|
||||
"""Wait and handle assistant function calls"""
|
||||
tool_outputs: list[dict] = []
|
||||
if run.required_action is None:
|
||||
return tool_outputs
|
||||
|
||||
for tool_call in run.required_action.submit_tool_outputs.tool_calls:
|
||||
tool_outputs.append(
|
||||
{
|
||||
"tool_call_id": tool_call.id,
|
||||
"output": json.dumps(
|
||||
self.execute_tool_call(
|
||||
tool_call=t.cast(ChatCompletionMessageToolCall, tool_call),
|
||||
user_id=user_id,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
return tool_outputs
|
||||
|
||||
def wait_and_handle_assistant_tool_calls(
|
||||
self,
|
||||
user_id: str,
|
||||
client: Client,
|
||||
run: Run,
|
||||
thread: Thread,
|
||||
) -> Run:
|
||||
"""Wait and handle assistant function calls"""
|
||||
while run.status in ("queued", "in_progress", "requires_action"):
|
||||
if run.status != "requires_action":
|
||||
run = client.beta.threads.runs.retrieve(
|
||||
thread_id=thread.id,
|
||||
run_id=run.id,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
run = client.beta.threads.runs.submit_tool_outputs(
|
||||
thread_id=thread.id,
|
||||
run_id=run.id,
|
||||
tool_outputs=self.handle_assistant_tool_calls(
|
||||
run=run,
|
||||
user_id=user_id,
|
||||
),
|
||||
)
|
||||
return run
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
OpenAI Responses API provider implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from openai.types.responses.response import Response
|
||||
from openai.types.responses.response_output_item import ResponseFunctionToolCall
|
||||
|
||||
from composio.core.provider import NonAgenticProvider
|
||||
from composio.types import Modifiers, Tool, ToolExecutionResponse
|
||||
from composio.utils.shared import normalize_tool_arguments
|
||||
|
||||
# Responses API uses a flattened tool structure
|
||||
ResponsesTool = t.Dict[str, t.Any]
|
||||
ResponsesToolCollection = t.List[ResponsesTool]
|
||||
|
||||
|
||||
class OpenAIResponsesProvider(
|
||||
NonAgenticProvider[ResponsesTool, ResponsesToolCollection], name="openai_responses"
|
||||
):
|
||||
"""OpenAI Responses API Provider class definition."""
|
||||
|
||||
def wrap_tool(self, tool: Tool) -> ResponsesTool:
|
||||
"""Wrap a tool for the Responses API format."""
|
||||
return {
|
||||
"type": "function",
|
||||
"name": tool.slug,
|
||||
"description": tool.description,
|
||||
"parameters": tool.input_parameters,
|
||||
}
|
||||
|
||||
def wrap_tools(self, tools: t.Sequence[Tool]) -> ResponsesToolCollection:
|
||||
"""Wrap multiple tools for the Responses API format."""
|
||||
return [self.wrap_tool(tool) for tool in tools]
|
||||
|
||||
def execute_tool_call(
|
||||
self,
|
||||
user_id: str,
|
||||
tool_call: t.Union[ResponseFunctionToolCall],
|
||||
modifiers: t.Optional[Modifiers] = None,
|
||||
) -> ToolExecutionResponse:
|
||||
"""Execute a tool call from the Responses API.
|
||||
|
||||
:param tool_call: Tool call metadata from Responses API.
|
||||
:param user_id: User ID to use for executing the function call.
|
||||
:param modifiers: Optional modifiers for tool execution.
|
||||
:return: Object containing output data from the tool call.
|
||||
"""
|
||||
# OpenAI always serializes tool arguments as a JSON string; normalize
|
||||
# tolerates empty / object-shaped payloads too (issue #2406).
|
||||
slug = tool_call.name
|
||||
arguments = normalize_tool_arguments(tool_call.arguments)
|
||||
|
||||
return self.execute_tool(
|
||||
slug=slug,
|
||||
arguments=arguments,
|
||||
modifiers=modifiers,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
def handle_tool_calls(
|
||||
self,
|
||||
user_id: str,
|
||||
response: Response,
|
||||
modifiers: t.Optional[Modifiers] = None,
|
||||
) -> t.List[ToolExecutionResponse]:
|
||||
"""
|
||||
Handle tool calls from OpenAI Responses API.
|
||||
|
||||
:param response: Response object from openai.OpenAI.beta.responses.create
|
||||
:param user_id: User ID to use for executing the function call.
|
||||
:param modifiers: Optional modifiers for tool execution
|
||||
:return: List[ToolExecutionResponse] with tool execution results
|
||||
"""
|
||||
outputs = []
|
||||
|
||||
if response.output:
|
||||
for item in response.output:
|
||||
if isinstance(item, ResponseFunctionToolCall):
|
||||
result = self.execute_tool_call(
|
||||
user_id=user_id,
|
||||
tool_call=item,
|
||||
modifiers=modifiers,
|
||||
)
|
||||
outputs.append(result)
|
||||
|
||||
return outputs
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from composio.client.types import Tool
|
||||
from composio.core.provider.base import BaseProvider, TTool, TToolCollection
|
||||
|
||||
|
||||
class AgenticProviderExecuteFn(t.Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
slug: str,
|
||||
arguments: t.Dict,
|
||||
) -> t.Dict:
|
||||
"""
|
||||
Execute a wrapped tool by slug, passing an arbitrary input dict.
|
||||
Returns a dict with the following keys:
|
||||
- data: The data returned by the tool.
|
||||
- error: The error returned by the tool.
|
||||
- successful: Whether the tool was successful.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class AgenticProvider(BaseProvider[TTool, TToolCollection]):
|
||||
"""
|
||||
Base class for all agentic providers. This class is not meant to be used
|
||||
directly but rather to be extended by concrete provider implementations.
|
||||
"""
|
||||
|
||||
def __init_subclass__(cls, name: str) -> None:
|
||||
cls.name = name
|
||||
|
||||
def wrap_tool(
|
||||
self,
|
||||
tool: Tool,
|
||||
execute_tool: AgenticProviderExecuteFn,
|
||||
) -> TTool:
|
||||
"""Wrap a tool in the provider-specific format"""
|
||||
raise NotImplementedError
|
||||
|
||||
def wrap_tools(
|
||||
self,
|
||||
tools: t.Sequence[Tool],
|
||||
execute_tool: AgenticProviderExecuteFn,
|
||||
) -> TToolCollection:
|
||||
"""Wrap a list of tools in the provider-specific format"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
BaseProvider module
|
||||
|
||||
Defines the barebones provider metaclass that needs to be subclassed for every provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
import typing_extensions as te
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from composio.core.models.tools import Modifiers, ToolExecutionResponse
|
||||
|
||||
TTool = t.TypeVar("TTool")
|
||||
TToolCollection = t.TypeVar("TToolCollection")
|
||||
|
||||
|
||||
class ExecuteToolFn(t.Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
slug: str,
|
||||
arguments: t.Dict,
|
||||
*,
|
||||
modifiers: t.Optional[Modifiers] = None,
|
||||
user_id: t.Optional[str] = None,
|
||||
) -> ToolExecutionResponse:
|
||||
"""
|
||||
Execute a wrapped tool by slug, passing an arbitrary input dict.
|
||||
This function is used by the providers to execute tools for the helper methods.
|
||||
Returns a dict with the following keys:
|
||||
- data: The data returned by the tool.
|
||||
- error: The error returned by the tool.
|
||||
- successful: Whether the tool was successful.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class SchemaConfig(te.TypedDict):
|
||||
skip_defaults: te.NotRequired[bool]
|
||||
|
||||
|
||||
class BaseProviderConfig(te.TypedDict):
|
||||
schema_config: te.NotRequired[SchemaConfig]
|
||||
|
||||
|
||||
class BaseProvider(t.Generic[TTool, TToolCollection]):
|
||||
"""
|
||||
BaseProvider class
|
||||
|
||||
All providers should inherit from this class and implement `wrap_tools` so that
|
||||
they can be used with the core Composio class.
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""Name of the provider"""
|
||||
|
||||
__schema_skip_defaults__ = False
|
||||
|
||||
execute_tool: ExecuteToolFn
|
||||
"""
|
||||
The function to execute a tool for the provider's helper methods.
|
||||
This is automatically injected by the core SDK.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: t.Unpack[BaseProviderConfig]) -> None:
|
||||
self.skip_default = kwargs.get("schema_config", {}).get(
|
||||
"skip_defaults", self.__schema_skip_defaults__
|
||||
)
|
||||
|
||||
def set_execute_tool_fn(self, execute_tool_fn: ExecuteToolFn) -> None:
|
||||
self.execute_tool = execute_tool_fn
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from composio.client.types import Tool
|
||||
from composio.core.provider.base import BaseProvider, TTool, TToolCollection
|
||||
|
||||
|
||||
class NonAgenticProvider(BaseProvider[TTool, TToolCollection]):
|
||||
"""
|
||||
Base class for all non-agentic providers, such as `openai` This class is not
|
||||
meant to be used directly, but rather to be extended by concrete implementations
|
||||
This version doesn't have the execute_tool_fn for `wrap_tool` and `wrap_tools`
|
||||
"""
|
||||
|
||||
def __init_subclass__(cls, name: str) -> None:
|
||||
cls.name = name
|
||||
|
||||
def wrap_tool(
|
||||
self,
|
||||
tool: Tool,
|
||||
) -> TTool:
|
||||
"""Wrap a tool in the provider-specific format"""
|
||||
raise NotImplementedError
|
||||
|
||||
def wrap_tools(
|
||||
self,
|
||||
tools: t.Sequence[Tool],
|
||||
) -> TToolCollection:
|
||||
"""Wrap a list of tools in the provider-specific format"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Core types for Composio SDK."""
|
||||
|
||||
import typing as t
|
||||
|
||||
import typing_extensions as te
|
||||
|
||||
# Tool versioning types
|
||||
ToolkitLatestVersion = te.Literal["latest"]
|
||||
ToolkitVersion = t.Union[
|
||||
ToolkitLatestVersion, str
|
||||
] # Can be "latest" or any version string like "20250906_01"
|
||||
ToolkitVersions = t.Dict[str, ToolkitVersion]
|
||||
ToolkitVersionParam = t.Union[ToolkitVersions, ToolkitLatestVersion, None]
|
||||
|
||||
__all__ = [
|
||||
"ToolkitLatestVersion",
|
||||
"ToolkitVersion",
|
||||
"ToolkitVersions",
|
||||
"ToolkitVersionParam",
|
||||
]
|
||||
Reference in New Issue
Block a user