chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
from .__version__ import __version__
|
||||
from .core.models.custom_tool import ExperimentalToolkit
|
||||
from .core.models.custom_tool_types import (
|
||||
CustomTool,
|
||||
SessionContext,
|
||||
)
|
||||
from .core.models.tool_router_constants import SESSION_PRESET_DIRECT_TOOLS
|
||||
from .core.models.tool_router_session_files import RemoteFile
|
||||
from .core.models.tools import (
|
||||
after_execute,
|
||||
before_execute,
|
||||
before_file_upload,
|
||||
schema_modifier,
|
||||
)
|
||||
from .core.models.webhook_events import (
|
||||
ConnectionExpiredEvent,
|
||||
ConnectionState,
|
||||
ConnectionStatusEnum,
|
||||
SingleConnectedAccountDetailedResponse,
|
||||
WebhookConnectionMetadata,
|
||||
WebhookEvent,
|
||||
WebhookEventType,
|
||||
is_connection_expired_event,
|
||||
)
|
||||
from .core.types import (
|
||||
ToolkitLatestVersion,
|
||||
ToolkitVersion,
|
||||
ToolkitVersionParam,
|
||||
ToolkitVersions,
|
||||
)
|
||||
from .sdk import Composio
|
||||
|
||||
__all__ = (
|
||||
"Composio",
|
||||
"CustomTool",
|
||||
"ExperimentalToolkit",
|
||||
"RemoteFile",
|
||||
"SessionContext",
|
||||
"SESSION_PRESET_DIRECT_TOOLS",
|
||||
"ConnectionExpiredEvent",
|
||||
"ConnectionState",
|
||||
"ConnectionStatusEnum",
|
||||
"SingleConnectedAccountDetailedResponse",
|
||||
"WebhookConnectionMetadata",
|
||||
"WebhookEvent",
|
||||
"WebhookEventType",
|
||||
"after_execute",
|
||||
"before_execute",
|
||||
"before_file_upload",
|
||||
"is_connection_expired_event",
|
||||
"schema_modifier",
|
||||
"__version__",
|
||||
"ToolkitLatestVersion",
|
||||
"ToolkitVersion",
|
||||
"ToolkitVersions",
|
||||
"ToolkitVersionParam",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.17.1"
|
||||
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
This module is a light wrapper around the auto-generated composio client.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import os
|
||||
import platform
|
||||
import typing as t
|
||||
from importlib.metadata import version
|
||||
from uuid import uuid4
|
||||
|
||||
import typing_extensions as te
|
||||
from composio_client import (
|
||||
DEFAULT_MAX_RETRIES,
|
||||
NOT_GIVEN,
|
||||
APIError,
|
||||
NotGiven,
|
||||
_base_client,
|
||||
)
|
||||
from composio_client import Composio as BaseComposio
|
||||
from httpx import URL, Client, Request, Timeout
|
||||
|
||||
from composio.utils.logging import WithLogger
|
||||
|
||||
ComposioAPIError = APIError
|
||||
APIEnvironment = te.Literal["production", "staging", "local"]
|
||||
|
||||
|
||||
def _get_python_implementation() -> str:
|
||||
"""
|
||||
Get the Python implementation name.
|
||||
|
||||
Returns:
|
||||
String identifier for Python implementation (CPYTHON, PYPY, JYTHON, IRONPYTHON, etc.)
|
||||
"""
|
||||
impl = platform.python_implementation().upper()
|
||||
return impl
|
||||
|
||||
|
||||
def _detect_runtime_environment() -> str:
|
||||
"""
|
||||
Detect the runtime environment where the code is executing.
|
||||
|
||||
Returns a string identifier for the environment.
|
||||
"""
|
||||
# Check for Google Colab
|
||||
try:
|
||||
import google.colab # type: ignore # noqa: F401
|
||||
|
||||
return "GOOGLE_COLAB"
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Check for Jupyter/IPython
|
||||
try:
|
||||
shell = get_ipython().__class__.__name__ # type: ignore # noqa: F821
|
||||
if shell == "ZMQInteractiveShell":
|
||||
return "JUPYTER_NOTEBOOK"
|
||||
elif shell == "TerminalInteractiveShell":
|
||||
return "IPYTHON"
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
# Check for AWS Lambda
|
||||
if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"):
|
||||
return "AWS_LAMBDA"
|
||||
|
||||
# Check for Google Cloud Functions
|
||||
if os.environ.get("FUNCTION_NAME") or os.environ.get("K_SERVICE"):
|
||||
return "GOOGLE_CLOUD_FUNCTION"
|
||||
|
||||
# Check for Azure Functions
|
||||
if os.environ.get("FUNCTIONS_WORKER_RUNTIME"):
|
||||
return "AZURE_FUNCTION"
|
||||
|
||||
# Check for Kaggle
|
||||
if os.environ.get("KAGGLE_KERNEL_RUN_TYPE"):
|
||||
return "KAGGLE"
|
||||
|
||||
# Check for Replit
|
||||
if os.environ.get("REPL_ID") or os.environ.get("REPLIT_DB_URL"):
|
||||
return "REPLIT"
|
||||
|
||||
# Check for GitHub Actions
|
||||
if os.environ.get("GITHUB_ACTIONS"):
|
||||
return "GITHUB_ACTIONS"
|
||||
|
||||
# Check for GitLab CI
|
||||
if os.environ.get("GITLAB_CI"):
|
||||
return "GITLAB_CI"
|
||||
|
||||
# Check for CircleCI
|
||||
if os.environ.get("CIRCLECI"):
|
||||
return "CIRCLECI"
|
||||
|
||||
# Check for Jenkins
|
||||
if os.environ.get("JENKINS_HOME"):
|
||||
return "JENKINS"
|
||||
|
||||
# Check for Docker
|
||||
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
|
||||
return "DOCKER"
|
||||
|
||||
# Check if running in a container (generic)
|
||||
try:
|
||||
with open("/proc/1/cgroup", "r") as f:
|
||||
if "docker" in f.read() or "containerd" in f.read():
|
||||
return "CONTAINER"
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
|
||||
# Default to LOCAL for development environments
|
||||
return "LOCAL"
|
||||
|
||||
|
||||
class RequestContext(te.TypedDict):
|
||||
id: te.NotRequired[t.Optional[str]]
|
||||
provider: str
|
||||
|
||||
|
||||
# TODO: Rename `Composio` to `HttpClient` in stainless generator
|
||||
class HttpClient(BaseComposio, WithLogger):
|
||||
"""
|
||||
Wrapper around the auto-generated composio client.
|
||||
"""
|
||||
|
||||
request_ctx: contextvars.ContextVar[RequestContext]
|
||||
not_given = NOT_GIVEN
|
||||
|
||||
# Detect once at class initialization
|
||||
_runtime_env: str = (
|
||||
f"{_detect_runtime_environment()}_{_get_python_implementation()}"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
provider: str,
|
||||
api_key: t.Optional[str] = None,
|
||||
environment: te.Union[NotGiven, APIEnvironment] = "production",
|
||||
base_url: t.Optional[t.Union[str, URL, NotGiven]] = NOT_GIVEN,
|
||||
timeout: t.Optional[t.Union[float, Timeout, NotGiven]] = NOT_GIVEN,
|
||||
max_retries: int = DEFAULT_MAX_RETRIES,
|
||||
default_headers: t.Optional[t.Mapping[str, str]] = None,
|
||||
default_query: t.Optional[t.Mapping[str, object]] = None,
|
||||
http_client: t.Optional[Client] = None,
|
||||
_strict_response_validation: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the client.
|
||||
|
||||
:param provider: The provider to use for the client.
|
||||
:param api_key: The API key to use for the client.
|
||||
:param environment: The environment to use for the client.
|
||||
:param base_url: The base URL to use for the client.
|
||||
:param timeout: The timeout to use for the client.
|
||||
:param max_retries: The maximum number of retries to use for the client.
|
||||
:param default_headers: The default headers to use for the client.
|
||||
:param default_query: The default query parameters to use for the client.
|
||||
:param http_client: The HTTP client to use for the client.
|
||||
"""
|
||||
WithLogger.__init__(self)
|
||||
BaseComposio.__init__(
|
||||
self,
|
||||
api_key=api_key,
|
||||
environment=environment,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
default_headers=default_headers,
|
||||
default_query=default_query,
|
||||
http_client=http_client,
|
||||
_strict_response_validation=_strict_response_validation,
|
||||
)
|
||||
# TOFIX: Verbosity wrapper impl
|
||||
_base_client.log = self._logger # type: ignore
|
||||
self.provider = provider
|
||||
self.request_ctx = contextvars.ContextVar[RequestContext](
|
||||
"request_ctx",
|
||||
default={
|
||||
"id": None,
|
||||
"provider": provider,
|
||||
},
|
||||
)
|
||||
# Lazily-built sibling client with retries disabled; see `without_retries`.
|
||||
self._without_retries: t.Optional[te.Self] = None
|
||||
|
||||
def copy( # type: ignore[override]
|
||||
self,
|
||||
*,
|
||||
_extra_kwargs: t.Mapping[str, t.Any] = {},
|
||||
**kwargs: t.Any,
|
||||
) -> te.Self:
|
||||
"""
|
||||
Clone the client, re-injecting the required ``provider`` keyword.
|
||||
|
||||
The Stainless-generated ``copy`` rebuilds the client via
|
||||
``self.__class__(...)`` without passing ``provider``, which this subclass
|
||||
requires — so the inherited ``copy``/``with_options`` raise ``TypeError``.
|
||||
Threading ``provider`` through ``_extra_kwargs`` makes them work again
|
||||
(e.g. ``with_options(max_retries=0)``).
|
||||
"""
|
||||
return super().copy( # type: ignore[misc]
|
||||
_extra_kwargs={
|
||||
"provider": self.provider,
|
||||
# The generated `copy` does not re-pass `_strict_response_validation`,
|
||||
# so without this the clone would silently fall back to the default
|
||||
# (False) even when the original had it enabled — keeping the sibling
|
||||
# a faithful copy that differs from the parent only in `max_retries`.
|
||||
"_strict_response_validation": self._strict_response_validation,
|
||||
**_extra_kwargs,
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Re-alias `with_options` to this override. The base class binds
|
||||
# `with_options = copy` at class-definition time, so without this it would
|
||||
# still resolve to the base `copy` and miss the `provider` re-injection.
|
||||
with_options = copy
|
||||
|
||||
@property
|
||||
def without_retries(self) -> te.Self:
|
||||
"""
|
||||
A cached sibling client that never retries requests.
|
||||
|
||||
Used for non-idempotent writes (``tools.execute`` / ``tools.proxy``),
|
||||
where a silent retry after a read timeout can duplicate a side effect
|
||||
(e.g. send an email twice). Reads keep the default retry behaviour.
|
||||
|
||||
Scope: only ``tools.execute`` / ``tools.proxy`` route through this today.
|
||||
Other non-idempotent writes (``auth_configs.create`` / ``update`` /
|
||||
``delete``, ``mcp.update`` / ``delete``, ``connected_accounts.delete`` /
|
||||
``refresh``, ``link.create``) keep the default retries — most are
|
||||
naturally idempotent on retry, and the durable fix is backend-honoured
|
||||
idempotency keys.
|
||||
|
||||
The sibling is cached rather than rebuilt per call so a fresh client is
|
||||
not constructed on every execute/proxy (the hottest path); its options
|
||||
never change, so one per client suffices.
|
||||
"""
|
||||
if self._without_retries is None:
|
||||
self._without_retries = self.with_options(max_retries=0)
|
||||
return self._without_retries
|
||||
|
||||
def _prepare_request(self, request: Request) -> None:
|
||||
"""
|
||||
Request interceptor to inject request id, provider, and SDK version.
|
||||
"""
|
||||
ctx = self.request_ctx.get()
|
||||
request.headers["x-request-id"] = ctx.get("id") or uuid4().hex
|
||||
request.headers["x-framework"] = ctx["provider"]
|
||||
request.headers["x-source"] = "PYTHON_SDK"
|
||||
request.headers["x-runtime"] = HttpClient._runtime_env
|
||||
|
||||
try:
|
||||
request.headers["x-sdk-version"] = version("composio")
|
||||
except Exception:
|
||||
request.headers["x-sdk-version"] = "unknown"
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
This module is a light wrapper around the auto-generated composio client types.
|
||||
"""
|
||||
|
||||
import typing as t
|
||||
|
||||
from composio_client import NotGiven
|
||||
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,
|
||||
connected_account_create_params,
|
||||
connected_account_create_response,
|
||||
connected_account_list_params,
|
||||
connected_account_list_response,
|
||||
connected_account_patch_params,
|
||||
connected_account_patch_response,
|
||||
connected_account_retrieve_response,
|
||||
connected_account_update_status_response,
|
||||
link_create_params,
|
||||
tool_execute_params,
|
||||
tool_execute_response,
|
||||
tool_list_response,
|
||||
tool_proxy_params,
|
||||
tool_proxy_response,
|
||||
toolkit_list_params,
|
||||
toolkit_list_response,
|
||||
toolkit_retrieve_response,
|
||||
trigger_instance_upsert_response,
|
||||
)
|
||||
|
||||
Tool: t.TypeAlias = tool_list_response.Item
|
||||
ToolkitMinimal: t.TypeAlias = tool_list_response.ItemToolkit
|
||||
AuthConfig: t.TypeAlias = connected_account_create_params.AuthConfig
|
||||
|
||||
Oauth1L: t.TypeAlias = t.Literal["OAUTH1"]
|
||||
Oauth2L: t.TypeAlias = t.Literal["OAUTH2"]
|
||||
ApiKeyL: t.TypeAlias = t.Literal["API_KEY"]
|
||||
BasicL: t.TypeAlias = t.Literal["BASIC"]
|
||||
NoAuthL: t.TypeAlias = t.Literal["NO_AUTH"]
|
||||
SnowflakeL: t.TypeAlias = t.Literal["SNOWFLAKE"]
|
||||
CalcomAuthL: t.TypeAlias = t.Literal["CALCOM_AUTH"]
|
||||
BearerTokenL: t.TypeAlias = t.Literal["BEARER_TOKEN"]
|
||||
BillcomAuthL: t.TypeAlias = t.Literal["BILLCOM_AUTH"]
|
||||
ComposioLinkL: t.TypeAlias = t.Literal["COMPOSIO_LINK"]
|
||||
BasicWithJwtL: t.TypeAlias = t.Literal["BASIC_WITH_JWT"]
|
||||
GoogleServiceAccountL: t.TypeAlias = t.Literal["GOOGLE_SERVICE_ACCOUNT"]
|
||||
|
||||
AuthSchemeL: t.TypeAlias = t.Literal[
|
||||
Oauth1L,
|
||||
Oauth2L,
|
||||
ApiKeyL,
|
||||
BasicL,
|
||||
NoAuthL,
|
||||
SnowflakeL,
|
||||
CalcomAuthL,
|
||||
BearerTokenL,
|
||||
BillcomAuthL,
|
||||
ComposioLinkL,
|
||||
BasicWithJwtL,
|
||||
GoogleServiceAccountL,
|
||||
]
|
||||
|
||||
__all__ = (
|
||||
"auth_config_create_params",
|
||||
"auth_config_create_response",
|
||||
"auth_config_list_params",
|
||||
"auth_config_list_response",
|
||||
"auth_config_retrieve_response",
|
||||
"auth_config_update_params",
|
||||
"connected_account_create_params",
|
||||
"connected_account_create_response",
|
||||
"connected_account_list_params",
|
||||
"connected_account_list_response",
|
||||
"connected_account_patch_params",
|
||||
"connected_account_patch_response",
|
||||
"connected_account_retrieve_response",
|
||||
"connected_account_update_status_response",
|
||||
"link_create_params",
|
||||
"trigger_instance_upsert_response",
|
||||
"tool_execute_params",
|
||||
"tool_execute_response",
|
||||
"tool_list_response",
|
||||
"tool_proxy_params",
|
||||
"tool_proxy_response",
|
||||
"toolkit_list_params",
|
||||
"toolkit_list_response",
|
||||
"toolkit_retrieve_response",
|
||||
"Tool",
|
||||
"ToolkitMinimal",
|
||||
"AuthConfig",
|
||||
"NotGiven",
|
||||
"AuthSchemeL",
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
Composio exceptions.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import typing as t
|
||||
|
||||
ENV_COMPOSIO_API_KEY = "COMPOSIO_API_KEY"
|
||||
|
||||
|
||||
class ComposioError(Exception):
|
||||
"""Base composio SDK error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*args: t.Any,
|
||||
delegate: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize Composio SDK error.
|
||||
|
||||
:param message: Error message
|
||||
:param delegate: Whether to delegate the error message to the log
|
||||
collection server or not
|
||||
"""
|
||||
super().__init__(message, *args)
|
||||
self.message = message
|
||||
self.delegate = delegate
|
||||
|
||||
|
||||
class NotFoundError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class HTTPError(ComposioError):
|
||||
"""
|
||||
Exception class for HTTP API errors.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int,
|
||||
*args: t.Any,
|
||||
delegate: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize HTTPError class.
|
||||
|
||||
:param message: Content from the API response
|
||||
:param status_code: HTTP response status code
|
||||
:param delegate: Whether to delegate the error message to the log
|
||||
collection server or not
|
||||
"""
|
||||
super().__init__(message, *args, delegate=delegate)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class ComposioClientError(ComposioError):
|
||||
"""
|
||||
Exception class for Composio client errors.
|
||||
"""
|
||||
|
||||
|
||||
class SDKError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class ProcessorError(SDKError):
|
||||
pass
|
||||
|
||||
|
||||
class EnumError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class JSONSchemaRefResolutionError(ValidationError):
|
||||
"""Raised when an internal JSON Schema ``$ref`` cannot be inlined.
|
||||
|
||||
Covers malformed pointers, missing ``$defs``/``definitions`` targets
|
||||
(strict mode only), and ``$ref`` chains or node nesting that exceed the
|
||||
safety caps in :func:`composio.utils.json_schema.dereference_json_schema`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*args: t.Any,
|
||||
meta: t.Optional[t.Dict[str, t.Any]] = None,
|
||||
delegate: bool = False,
|
||||
) -> None:
|
||||
super().__init__(message, *args, delegate=delegate)
|
||||
self.meta = meta or {}
|
||||
|
||||
|
||||
class ToolkitError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class EntityIDError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class PluginError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidParams(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class FileError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class ComposioSDKTimeoutError(ComposioError, TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
class SDKFileNotFoundError(ComposioError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
|
||||
class LockFileError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class VersionError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidLockFile(LockFileError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidVersionString(EnumError):
|
||||
pass
|
||||
|
||||
|
||||
class VersionSelectionError(LockFileError, VersionError):
|
||||
def __init__(
|
||||
self,
|
||||
action: str,
|
||||
requested: str,
|
||||
locked: str,
|
||||
delegate: bool = False,
|
||||
) -> None:
|
||||
self.action = action
|
||||
self.requested = requested
|
||||
self.locked = locked
|
||||
super().__init__(
|
||||
message=(
|
||||
f"Error selecting version for action: {action!r}, "
|
||||
f"requested: {requested!r}, locked: {locked!r}"
|
||||
),
|
||||
delegate=delegate,
|
||||
)
|
||||
|
||||
|
||||
class InvalidEnum(EnumError):
|
||||
pass
|
||||
|
||||
|
||||
class EnumStringNotFound(EnumError):
|
||||
"""Raise when user provides invalid enum string."""
|
||||
|
||||
def __init__(self, value: str, enum: str, possible_values: t.List[str]) -> None:
|
||||
error_message = f"Invalid value `{value}` for enum class `{enum}`"
|
||||
matches = difflib.get_close_matches(value, possible_values, n=1)
|
||||
if matches:
|
||||
(match,) = matches
|
||||
error_message += f". Did you mean {match!r}?"
|
||||
|
||||
super().__init__(message=error_message)
|
||||
|
||||
|
||||
class EnumMetadataNotFound(EnumError):
|
||||
pass
|
||||
|
||||
|
||||
class ErrorUploadingFile(FileError):
|
||||
pass
|
||||
|
||||
|
||||
class SensitiveFilePathBlockedError(FileError):
|
||||
"""Raised when a local file path is refused before upload (sensitive directory or credential-like name)."""
|
||||
|
||||
|
||||
class FileUploadPathNotAllowedError(FileError):
|
||||
"""
|
||||
Raised when automatic file upload during tool execution is attempted from a
|
||||
path outside the configured ``file_upload_dirs`` allowlist.
|
||||
|
||||
Only fires for auto-upload (enabled via
|
||||
``dangerously_allow_auto_upload_download_files=True``). Manual upload APIs
|
||||
are not subject to this check.
|
||||
"""
|
||||
|
||||
|
||||
class FileUploadAbortedError(FileError):
|
||||
"""Raised when a ``before_file_upload`` hook returns ``False``."""
|
||||
|
||||
|
||||
class ErrorDownloadingFile(FileError):
|
||||
pass
|
||||
|
||||
|
||||
class RemoteFileDownloadError(FileError):
|
||||
"""Raised when fetching a remote file from a tool router session mount fails.
|
||||
|
||||
Includes HTTP status, URL, and file path context for debugging.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Failed to download remote file",
|
||||
*,
|
||||
status_code: t.Optional[int] = None,
|
||||
status_text: t.Optional[str] = None,
|
||||
download_url: t.Optional[str] = None,
|
||||
mount_relative_path: t.Optional[str] = None,
|
||||
filename: t.Optional[str] = None,
|
||||
**kwargs: t.Any,
|
||||
) -> None:
|
||||
super().__init__(message, **kwargs)
|
||||
self.status_code = status_code
|
||||
self.status_text = status_text
|
||||
self.download_url = download_url
|
||||
self.mount_relative_path = mount_relative_path
|
||||
self.filename = filename
|
||||
|
||||
|
||||
class ResponseTooLargeError(FileError):
|
||||
"""Raised when a response exceeds the maximum allowed size."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TriggerError(ToolkitError):
|
||||
pass
|
||||
|
||||
|
||||
class WebhookSignatureVerificationError(TriggerError):
|
||||
"""Raised when webhook signature verification fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WebhookPayloadError(TriggerError):
|
||||
"""Raised when webhook payload is invalid."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TriggerTypeNotFound(TriggerError, NotFoundError):
|
||||
"""Raised when a trigger type cannot be found for the given slug.
|
||||
|
||||
Mirrors the TypeScript SDK's ``ComposioTriggerTypeNotFoundError``.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ActionError(ToolkitError):
|
||||
pass
|
||||
|
||||
|
||||
class TriggerSubscriptionError(TriggerError, ComposioClientError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidTriggerFilters(TriggerSubscriptionError):
|
||||
pass
|
||||
|
||||
|
||||
class ApiKeyError(ComposioClientError):
|
||||
pass
|
||||
|
||||
|
||||
class ApiKeyNotProvidedError(ApiKeyError, NotFoundError):
|
||||
"""Raise when API key is required but not provided."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
message=(
|
||||
"API Key not provided, either provide API key "
|
||||
f"or export it as `{ENV_COMPOSIO_API_KEY}` "
|
||||
"or run `composio login`"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ResourceError(ComposioClientError):
|
||||
pass
|
||||
|
||||
|
||||
class NoItemsFound(ResourceError):
|
||||
"""
|
||||
Exception class for empty collection values.
|
||||
"""
|
||||
|
||||
|
||||
class ErrorFetchingResource(ResourceError):
|
||||
pass
|
||||
|
||||
|
||||
class SchemaError(ToolkitError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidSchemaError(SchemaError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidEntityIdError(EntityIDError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationError(ResourceError):
|
||||
pass
|
||||
|
||||
|
||||
class ConnectedAccountError(ResourceError):
|
||||
pass
|
||||
|
||||
|
||||
class ConnectedAccountNotFoundError(NotFoundError, ConnectedAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidConnectedAccount(ValidationError, ConnectedAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class ComposioMultipleConnectedAccountsError(ConnectedAccountError):
|
||||
"""Raised when multiple connected accounts are found for a user and auth config."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ComposioLegacyConnectedAccountsEndpointRetiredError(ConnectedAccountError):
|
||||
"""Raised by ``composio.connected_accounts.initiate()`` when the legacy
|
||||
``POST /api/v3/connected_accounts`` endpoint rejects a Composio-managed
|
||||
OAuth (OAuth1, OAuth2, DCR_OAUTH) auth-config request.
|
||||
|
||||
Cutover dates: 2026-05-08 (new orgs), 2026-07-03 (all remaining orgs).
|
||||
Migrate to ``composio.connected_accounts.link()`` — same return shape,
|
||||
works for every redirectable scheme regardless of whether the auth
|
||||
config is Composio-managed or custom.
|
||||
|
||||
See: https://docs.composio.dev/docs/changelog/2026/04/24
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ComposioAclOnlyForSharedError(ConnectedAccountError):
|
||||
"""Raised when ACL fields (``allow_all_users``, ``allowed_user_ids``,
|
||||
``not_allowed_user_ids``) are sent on a ``PRIVATE`` connection. ACL
|
||||
is only meaningful for ``SHARED`` connections.
|
||||
|
||||
Fix: use ``account_type='SHARED'``, or omit the ACL fields when
|
||||
creating/updating a PRIVATE connection.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ComposioSharedAccessDeniedError(ConnectedAccountError):
|
||||
"""Raised when a tool execution attempts to use a SHARED connected
|
||||
account but the requesting ``user_id`` is not allowed by the
|
||||
connection's ACL.
|
||||
|
||||
Surfaces when a SHARED connection is reached directly (e.g. via
|
||||
``composio.tools.execute(slug, connected_account_id=...)``) without
|
||||
going through a tool-router session.
|
||||
|
||||
Fix: ask the connection's creator to grant access via
|
||||
``composio.experimental.update_acl()`` — set ``allow_all_users``,
|
||||
add the ``user_id`` to ``allowed_user_ids``, or remove it from
|
||||
``not_allowed_user_ids``.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ComposioSharedConnectionNotAccessibleError(ConnectedAccountError):
|
||||
"""Raised by ``tool_router.session.create()`` / ``session.patch()``
|
||||
when the session's ``user_id`` cannot use a pinned SHARED connection.
|
||||
Raised at session-create time so the session never enters a state
|
||||
that fails mid-execution.
|
||||
|
||||
Fix: grant the session user access via
|
||||
``composio.experimental.update_acl()`` on the pinned connection,
|
||||
or pin a different connection the user can use.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ErrorProcessingToolExecutionRequest(PluginError):
|
||||
pass
|
||||
|
||||
|
||||
class DescopeAuthError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class DescopeConfigError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidExecuteFunctionError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class ToolNotFoundError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidModifier(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class ExecuteToolFnNotSetError(ComposioError):
|
||||
pass
|
||||
|
||||
|
||||
class ToolVersionRequiredError(ComposioError):
|
||||
"""Raised when toolkit version is not specified for manual tool execution."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
message=(
|
||||
"Toolkit version not specified. For manual execution of the tool "
|
||||
"please pass a specific toolkit version.\n\n"
|
||||
"Possible fixes:\n"
|
||||
"1. Pass the toolkit version as a parameter to the execute function "
|
||||
'("latest" is not supported in manual execution)\n'
|
||||
"2. Set the toolkit versions in the Composio config "
|
||||
"(toolkit_versions={'<toolkit-slug>': '<toolkit-version>'})\n"
|
||||
"3. Set the toolkit version in the environment variable "
|
||||
"(COMPOSIO_TOOLKIT_VERSION_<TOOLKIT_SLUG>)\n"
|
||||
"4. Set dangerously_skip_version_check to True "
|
||||
"(this might cause unexpected behavior when new versions of the tools are released)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UsageError(ComposioError):
|
||||
pass
|
||||
@@ -0,0 +1,36 @@
|
||||
# Integration Tests
|
||||
|
||||
Integration tests for Composio SDK functionality.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export COMPOSIO_API_KEY="your_api_key_here"
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
cd /path/to/composio
|
||||
python -m pytest python/composio/integration_test/ -v
|
||||
|
||||
# From python directory
|
||||
cd /path/to/composio/python
|
||||
pytest composio/integration_test/ -v
|
||||
|
||||
# Using uv
|
||||
cd /path/to/composio/python
|
||||
uv run pytest composio/integration_test/ -v
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
- **`test_mcp.py`** - MCP (Model Context Protocol) functionality tests
|
||||
- **`test_tool_router.py`** - ToolRouter experimental feature tests
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.12+
|
||||
- pytest
|
||||
- Valid Composio API key
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Integration test package for Composio SDK.
|
||||
|
||||
This package contains comprehensive integration tests for various Composio features,
|
||||
including experimental MCP functionality.
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Pytest configuration and shared fixtures for Composio integration tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the python directory to the path for imports
|
||||
python_dir = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(python_dir))
|
||||
|
||||
# Test configuration
|
||||
API_KEY = os.getenv("COMPOSIO_API_KEY")
|
||||
|
||||
if not API_KEY:
|
||||
pytest.skip(
|
||||
"COMPOSIO_API_KEY environment variable not set", allow_module_level=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_environment():
|
||||
"""Set up the test environment for all tests."""
|
||||
os.environ["COMPOSIO_API_KEY"] = API_KEY
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def composio_client():
|
||||
"""Provide a Composio client instance for all tests."""
|
||||
from composio import Composio
|
||||
|
||||
return Composio()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def auth_configs(composio_client):
|
||||
"""Get available auth configurations for testing."""
|
||||
try:
|
||||
configs_response = composio_client.auth_configs.list()
|
||||
|
||||
# Debug what we get
|
||||
print(f"Raw response type: {type(configs_response)}")
|
||||
|
||||
# Handle different response formats
|
||||
if hasattr(configs_response, "items"):
|
||||
# Direct access to items
|
||||
items = configs_response.items
|
||||
print(f"Found items directly: {len(items) if items else 0}")
|
||||
return list(items) if items else []
|
||||
elif hasattr(configs_response, "__iter__"):
|
||||
# If it's being converted to an iterable of tuples, find the items tuple
|
||||
items_tuple = None
|
||||
for item in configs_response:
|
||||
if isinstance(item, tuple) and len(item) == 2 and item[0] == "items":
|
||||
items_tuple = item[1]
|
||||
break
|
||||
|
||||
if items_tuple:
|
||||
print(f"Found items in tuple: {len(items_tuple)}")
|
||||
return list(items_tuple)
|
||||
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"Exception in auth_configs fixture: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_mcp_config_data():
|
||||
"""Provide sample data for MCP configuration testing."""
|
||||
import time
|
||||
|
||||
return {
|
||||
"name": f"pytest_test_{int(time.time())}",
|
||||
"server_config": [
|
||||
{"auth_config_id": "test_auth_id", "allowed_tools": ["GMAIL_FETCH_EMAILS"]}
|
||||
],
|
||||
"options": {"is_chat_auth": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_id():
|
||||
"""Provide a consistent test user ID."""
|
||||
return "pytest_integration_user_123"
|
||||
|
||||
|
||||
# Track created resources for cleanup
|
||||
_created_mcp_servers = []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_server_cleanup(composio_client):
|
||||
"""Fixture to track and cleanup MCP servers created during tests."""
|
||||
created_servers = []
|
||||
|
||||
yield created_servers
|
||||
|
||||
# Cleanup after test
|
||||
for server_id in created_servers:
|
||||
try:
|
||||
composio_client.mcp.delete(server_id)
|
||||
print(f"✅ Cleaned up MCP server: {server_id}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to cleanup MCP server {server_id}: {e}")
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest with custom settings."""
|
||||
# Add timeout marker
|
||||
config.addinivalue_line(
|
||||
"markers", "timeout: mark test to run with a specific timeout"
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
[tool:pytest]
|
||||
# Simple pytest configuration for Composio integration tests
|
||||
|
||||
testpaths = .
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
|
||||
# Add timeout and other options
|
||||
# --timeout=120: Each test gets max 2 minutes
|
||||
# --timeout-method=thread: Use thread-based timeout (works cross-platform)
|
||||
addopts = -v --tb=short --timeout=120 --timeout-method=thread
|
||||
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||
integration: marks tests as integration tests
|
||||
@@ -0,0 +1,643 @@
|
||||
"""
|
||||
Comprehensive integration tests for Composio MCP functionality.
|
||||
|
||||
This module provides comprehensive pytest-based tests for the MCP (Model Context Protocol) functionality,
|
||||
combining both structured pytest tests and direct execution tests for non-auth toolkits.
|
||||
|
||||
Usage:
|
||||
export COMPOSIO_API_KEY="your_api_key_here"
|
||||
pytest python/composio/integration_test/test_mcp.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from composio import Composio
|
||||
from composio.exceptions import ValidationError
|
||||
|
||||
# Test configuration
|
||||
API_KEY = os.getenv("COMPOSIO_API_KEY")
|
||||
TEST_CONFIG_PREFIX = "pytest_integration_test"
|
||||
|
||||
if not API_KEY:
|
||||
pytest.fail("COMPOSIO_API_KEY environment variable not set", pytrace=False)
|
||||
|
||||
|
||||
def generate_unique_name(prefix: str = "pytest") -> str:
|
||||
"""Generate a unique test name using UUID to avoid collisions."""
|
||||
# Use first 8 characters of UUID for readability while maintaining uniqueness
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
return f"{prefix}-{unique_id}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def composio_client():
|
||||
"""Fixture providing Composio client instance."""
|
||||
return Composio(api_key=API_KEY)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_mcp_config_data():
|
||||
"""Fixture providing test data for MCP config creation."""
|
||||
return {
|
||||
"name": generate_unique_name("pytest-data"),
|
||||
"toolkits": ["composio_search", "text_to_pdf"],
|
||||
"allowed_tools": [
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
],
|
||||
"manually_manage_connections": False,
|
||||
}
|
||||
|
||||
|
||||
class TestMCPStructure:
|
||||
"""Test the basic structure and availability of MCP features."""
|
||||
|
||||
def test_mcp_namespace_exists(self, composio_client):
|
||||
"""Test that mcp namespace exists at top level."""
|
||||
assert hasattr(composio_client, "mcp"), "Missing mcp namespace"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method_name", ["create", "list", "get", "update", "delete", "generate"]
|
||||
)
|
||||
def test_mcp_methods_available(self, composio_client, method_name):
|
||||
"""Test that all required MCP methods are available."""
|
||||
assert hasattr(composio_client.mcp, method_name), (
|
||||
f"Missing method: {method_name}"
|
||||
)
|
||||
|
||||
|
||||
class TestMCPOperations:
|
||||
"""Test MCP CRUD operations."""
|
||||
|
||||
def test_list_mcp_configs(self, composio_client):
|
||||
"""Test listing MCP configurations."""
|
||||
try:
|
||||
configs = composio_client.mcp.list()
|
||||
assert isinstance(configs, dict), "list() should return a dictionary"
|
||||
assert "items" in configs, "Response should contain 'items' key"
|
||||
assert "current_page" in configs, (
|
||||
"Response should contain 'current_page' key"
|
||||
)
|
||||
assert "total_pages" in configs, "Response should contain 'total_pages' key"
|
||||
assert isinstance(configs["items"], list), "items should be a list"
|
||||
except Exception as e:
|
||||
# List might fail if no configs exist or API issues, but method should exist
|
||||
assert "Failed to list MCP servers" in str(e) or "list" in str(e).lower()
|
||||
|
||||
def test_list_with_pagination(self, composio_client):
|
||||
"""Test listing with pagination parameters."""
|
||||
try:
|
||||
configs = composio_client.mcp.list(page_no=1, limit=5)
|
||||
assert isinstance(configs, dict), (
|
||||
"Paginated list should return a dictionary"
|
||||
)
|
||||
assert "items" in configs, "Response should contain 'items'"
|
||||
except Exception as e:
|
||||
# Expected to fail with current API implementation
|
||||
assert "Failed to list MCP servers" in str(e)
|
||||
|
||||
def test_list_with_filters(self, composio_client):
|
||||
"""Test listing with filter parameters."""
|
||||
try:
|
||||
# Test toolkit filter with non-auth toolkits
|
||||
configs_search = composio_client.mcp.list(toolkits="composio_search")
|
||||
assert configs_search["items"] is None or isinstance(
|
||||
configs_search["items"], list
|
||||
)
|
||||
|
||||
# Test name filter
|
||||
configs_name = composio_client.mcp.list(name="test")
|
||||
assert configs_name["items"] is None or isinstance(
|
||||
configs_name["items"], list
|
||||
)
|
||||
except Exception as e:
|
||||
# Expected to fail with current API implementation
|
||||
assert "Failed to list MCP servers" in str(e)
|
||||
|
||||
def test_create_mcp_config(self, composio_client):
|
||||
"""Test creating MCP configuration with new API using non-auth toolkits."""
|
||||
# Server name must be ≤30 chars and only contain letters, numbers, spaces, hyphens
|
||||
test_name = generate_unique_name("pytest-create")
|
||||
|
||||
mcp_config = composio_client.mcp.create(
|
||||
test_name,
|
||||
toolkits=["composio_search", "text_to_pdf"],
|
||||
allowed_tools=[
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
],
|
||||
manually_manage_connections=False,
|
||||
)
|
||||
|
||||
# Basic response validation
|
||||
assert mcp_config.id
|
||||
assert mcp_config.name == test_name
|
||||
assert callable(mcp_config.generate)
|
||||
assert mcp_config.allowed_tools == [
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
]
|
||||
assert mcp_config.commands.claude
|
||||
assert mcp_config.commands.cursor
|
||||
assert mcp_config.commands.windsurf
|
||||
|
||||
# Test the generate method
|
||||
try:
|
||||
server_instance = mcp_config.generate("test_user_123")
|
||||
assert isinstance(server_instance, dict), (
|
||||
"generate should return a dictionary"
|
||||
)
|
||||
assert "id" in server_instance, "Server instance should have id"
|
||||
assert "url" in server_instance, "Server instance should have url"
|
||||
assert "type" in server_instance, "Server instance should have type"
|
||||
assert server_instance["type"] == "streamable_http", (
|
||||
"Server type should be streamable_http"
|
||||
)
|
||||
assert isinstance(server_instance["url"], str), "URL should be string"
|
||||
assert len(server_instance["url"]) > 0, "URL should not be empty"
|
||||
except Exception as e:
|
||||
print(f"Generate method failed (may be expected): {e}")
|
||||
|
||||
def test_get_nonexistent_config(self, composio_client):
|
||||
"""Test getting a non-existent configuration."""
|
||||
with pytest.raises(ValidationError):
|
||||
composio_client.mcp.get("nonexistent_config_id")
|
||||
|
||||
def test_create_with_empty_toolkits(self, composio_client):
|
||||
"""Test creating with empty toolkit configuration."""
|
||||
with pytest.raises(ValidationError):
|
||||
composio_client.mcp.create(
|
||||
"test_empty",
|
||||
toolkits=[], # Empty toolkits
|
||||
)
|
||||
|
||||
def test_generate_method_directly(self, composio_client):
|
||||
"""Test the generate method directly on MCP class using non-auth toolkits."""
|
||||
# First create a config
|
||||
test_name = generate_unique_name("pytest-generate")
|
||||
|
||||
mcp_config = composio_client.mcp.create(
|
||||
test_name,
|
||||
toolkits=["composio_search", "text_to_pdf"],
|
||||
allowed_tools=[
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
],
|
||||
manually_manage_connections=False,
|
||||
)
|
||||
|
||||
# Test generate method directly
|
||||
try:
|
||||
server_instance = composio_client.mcp.generate(
|
||||
"test_user_direct_123",
|
||||
mcp_config.id,
|
||||
{"manually_manage_connections": False},
|
||||
)
|
||||
|
||||
assert isinstance(server_instance, dict), (
|
||||
"generate should return a dictionary"
|
||||
)
|
||||
assert server_instance["id"] == mcp_config.id, (
|
||||
"Instance ID should match config ID"
|
||||
)
|
||||
assert server_instance["user_id"] == "test_user_direct_123", (
|
||||
"User ID should match"
|
||||
)
|
||||
assert server_instance["type"] == "streamable_http", (
|
||||
"Type should be streamable_http"
|
||||
)
|
||||
assert "url" in server_instance, "Should have URL"
|
||||
except Exception as e:
|
||||
print(f"Direct generate failed (may be expected): {e}")
|
||||
|
||||
def test_create_with_string_toolkits(self, composio_client):
|
||||
"""Test creating MCP configuration using simple string toolkit names."""
|
||||
test_name = generate_unique_name("pytest-strings")
|
||||
|
||||
# Test with simple string toolkit names
|
||||
mcp_config = composio_client.mcp.create(
|
||||
test_name,
|
||||
toolkits=["composio_search", "text_to_pdf"],
|
||||
manually_manage_connections=False,
|
||||
)
|
||||
|
||||
# Basic validation
|
||||
assert mcp_config.id
|
||||
assert mcp_config.name == test_name
|
||||
assert callable(mcp_config.generate)
|
||||
|
||||
def test_create_with_mixed_toolkits(self, composio_client):
|
||||
"""Test creating MCP configuration with mixed string and object formats."""
|
||||
test_name = generate_unique_name("pytest-mixed")
|
||||
|
||||
# Test with mixed formats (string and object with auth_config_id)
|
||||
mcp_config = composio_client.mcp.create(
|
||||
test_name,
|
||||
toolkits=[
|
||||
"composio_search", # String format
|
||||
{
|
||||
"toolkit": "text_to_pdf",
|
||||
# No auth_config_id needed for non-auth toolkit
|
||||
}, # Object format
|
||||
],
|
||||
allowed_tools=[
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
],
|
||||
manually_manage_connections=False,
|
||||
)
|
||||
|
||||
# Basic validation
|
||||
assert mcp_config.id
|
||||
assert mcp_config.name == test_name
|
||||
assert callable(mcp_config.generate)
|
||||
|
||||
def test_create_response_structure(self, composio_client):
|
||||
"""Test that create response has all required fields."""
|
||||
test_name = generate_unique_name("pytest-structure")
|
||||
|
||||
mcp_config = composio_client.mcp.create(
|
||||
test_name,
|
||||
toolkits=["composio_search", "text_to_pdf"],
|
||||
allowed_tools=[
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
],
|
||||
manually_manage_connections=False,
|
||||
)
|
||||
|
||||
# Basic structure validation
|
||||
assert mcp_config.id
|
||||
assert mcp_config.name == test_name
|
||||
assert mcp_config.allowed_tools == [
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
]
|
||||
assert mcp_config.auth_config_ids == []
|
||||
assert mcp_config.mcp_url
|
||||
assert mcp_config.commands.claude
|
||||
assert mcp_config.commands.cursor
|
||||
assert mcp_config.commands.windsurf
|
||||
assert callable(mcp_config.generate)
|
||||
|
||||
|
||||
class TestMCPErrorHandling:
|
||||
"""Test error handling and edge cases."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_config_id", ["", "invalid_id", "mcp_000000", "nonexistent"]
|
||||
)
|
||||
def test_invalid_config_ids(self, composio_client, invalid_config_id):
|
||||
"""Test various invalid configuration IDs."""
|
||||
with pytest.raises(ValidationError):
|
||||
composio_client.mcp.get(invalid_config_id)
|
||||
|
||||
def test_generate_with_invalid_params(self, composio_client):
|
||||
"""Test generate method with invalid parameters."""
|
||||
with pytest.raises(ValidationError):
|
||||
composio_client.mcp.generate("", "invalid_config_id")
|
||||
|
||||
def test_create_with_invalid_toolkit_config(self, composio_client):
|
||||
"""Test create with invalid toolkit configuration."""
|
||||
# Test with empty toolkit config - should fail during API call
|
||||
try:
|
||||
result = composio_client.mcp.create(
|
||||
"test",
|
||||
toolkits=[
|
||||
{
|
||||
# Empty toolkit config - will fail at API level
|
||||
}
|
||||
],
|
||||
)
|
||||
# If it somehow succeeds, that's unexpected but not necessarily wrong
|
||||
print(f"Create succeeded with empty config: {result.id}")
|
||||
except ValidationError as e:
|
||||
# Expected - should fail with validation error
|
||||
assert "Failed to create MCP server" in str(e)
|
||||
except Exception as e:
|
||||
# Also acceptable - might fail for other API reasons
|
||||
print(f"Create failed with: {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
class TestMCPNoAuthToolkits:
|
||||
"""Test MCP functionality with non-authentication toolkits."""
|
||||
|
||||
def test_mcp_with_no_auth_toolkits(self, composio_client):
|
||||
"""Test MCP with toolkits that don't require authentication."""
|
||||
print("🔧 Testing MCP with Non-Auth Toolkits")
|
||||
print("=" * 50)
|
||||
|
||||
# Create MCP server with non-auth toolkits
|
||||
server_name = generate_unique_name("no-auth-test")
|
||||
print(f"🚀 Creating MCP server: {server_name}")
|
||||
|
||||
mcp_server = composio_client.mcp.create(
|
||||
server_name,
|
||||
toolkits=["composio_search", "text_to_pdf"],
|
||||
allowed_tools=[
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
],
|
||||
manually_manage_connections=False,
|
||||
)
|
||||
|
||||
print("✅ MCP server created successfully!")
|
||||
print(f" Server ID: {mcp_server.id}")
|
||||
print(f" Server Name: {mcp_server.name}")
|
||||
print(f" Toolkits: {getattr(mcp_server, 'toolkits', 'N/A')}")
|
||||
|
||||
# Generate server instance for a test user
|
||||
test_user_id = "test_user_no_auth_123"
|
||||
print(f"\n🔗 Generating server instance for user: {test_user_id}")
|
||||
|
||||
server_instance = mcp_server.generate(test_user_id)
|
||||
|
||||
print("✅ Server instance generated successfully!")
|
||||
print(f" Instance ID: {server_instance['id']}")
|
||||
print(f" Instance Type: {server_instance['type']}")
|
||||
print(f" Instance URL: {server_instance['url']}")
|
||||
print(f" User ID: {server_instance['user_id']}")
|
||||
print(f" Allowed Tools: {server_instance['allowed_tools']}")
|
||||
print(f" Auth Configs: {server_instance['auth_configs']}")
|
||||
|
||||
# Test direct generate method as well
|
||||
print("\n🔄 Testing direct generate method...")
|
||||
|
||||
direct_instance = composio_client.mcp.generate(
|
||||
test_user_id + "_direct",
|
||||
mcp_server.id,
|
||||
{"manually_manage_connections": False},
|
||||
)
|
||||
|
||||
print("✅ Direct generate method successful!")
|
||||
print(f" Direct Instance URL: {direct_instance['url']}")
|
||||
print(f" Direct Instance User ID: {direct_instance['user_id']}")
|
||||
|
||||
# Test URL connectivity (basic check)
|
||||
print("\n🌐 Testing MCP URL connectivity...")
|
||||
|
||||
mcp_url = server_instance["url"]
|
||||
|
||||
try:
|
||||
# Just verify the URL is valid and endpoint exists
|
||||
# Don't try to read the stream as SSE endpoints can hang indefinitely
|
||||
headers = {
|
||||
"Accept": "text/event-stream, application/json, */*",
|
||||
"Cache-Control": "no-cache",
|
||||
"User-Agent": "Composio-Python-MCP-Test",
|
||||
}
|
||||
|
||||
# Use HEAD request if supported, otherwise quick GET with immediate close
|
||||
response = requests.head(mcp_url, headers=headers, timeout=3)
|
||||
|
||||
if response.status_code == 405: # Method not allowed for HEAD
|
||||
# Try GET but immediately close without reading stream
|
||||
response = requests.get(
|
||||
mcp_url, headers=headers, timeout=3, stream=True
|
||||
)
|
||||
response.close() # Close immediately without reading
|
||||
|
||||
print("✅ MCP URL is accessible!")
|
||||
print(f" Status Code: {response.status_code}")
|
||||
print(f" URL: {mcp_url[:50]}...")
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
print("⚠️ MCP URL timeout (normal for SSE endpoints)")
|
||||
except Exception as e:
|
||||
print(f"⚠️ MCP URL test failed: {e}")
|
||||
|
||||
print("\n📊 Test Summary:")
|
||||
print(f" ✅ MCP Server Created: {mcp_server.id}")
|
||||
print(f" ✅ Server Instance Generated: {server_instance['type']}")
|
||||
print(" ✅ Direct Generate Method: Working")
|
||||
print(f" ✅ Available Tools: {len(server_instance['allowed_tools'])} tools")
|
||||
print(
|
||||
f" ✅ No Auth Required: {len(server_instance['auth_configs'])} auth configs"
|
||||
)
|
||||
|
||||
# Assertions for pytest
|
||||
assert mcp_server.id is not None
|
||||
assert mcp_server.name == server_name
|
||||
assert server_instance["type"] == "streamable_http"
|
||||
assert server_instance["user_id"] == test_user_id
|
||||
assert len(server_instance["allowed_tools"]) > 0
|
||||
assert len(server_instance["auth_configs"]) == 0 # No auth required
|
||||
|
||||
def test_mcp_with_string_toolkits(self, composio_client):
|
||||
"""Test MCP server creation using simple string toolkit names."""
|
||||
print("🧪 Testing MCP with string toolkit names...")
|
||||
|
||||
# Create MCP server with string toolkit names (simplified API)
|
||||
server_name = generate_unique_name("string-test")
|
||||
print(f"🚀 Creating MCP server with strings: {server_name}")
|
||||
|
||||
mcp_server = composio_client.mcp.create(
|
||||
server_name,
|
||||
toolkits=["composio_search", "text_to_pdf"], # Simple strings
|
||||
manually_manage_connections=False,
|
||||
)
|
||||
|
||||
print("✅ MCP server created successfully with string toolkits!")
|
||||
print(f" Server ID: {mcp_server.id}")
|
||||
print(f" Server Name: {mcp_server.name}")
|
||||
|
||||
# Test generate method
|
||||
user_id = f"string_user_{str(uuid.uuid4())[:8]}"
|
||||
server_instance = mcp_server.generate(user_id)
|
||||
|
||||
print("✅ Server instance generated successfully!")
|
||||
print(f" Instance URL: {server_instance['url']}")
|
||||
print(f" User ID: {server_instance['user_id']}")
|
||||
print(f" Server Type: {server_instance['type']}")
|
||||
|
||||
# Basic validation
|
||||
assert server_instance["user_id"] == user_id
|
||||
assert server_instance["type"] == "streamable_http"
|
||||
assert "url" in server_instance
|
||||
assert len(server_instance["url"]) > 0
|
||||
|
||||
print("🎉 String toolkit test completed successfully!")
|
||||
|
||||
|
||||
class TestMCPRealWorldScenarios:
|
||||
"""Test real-world usage scenarios."""
|
||||
|
||||
def test_full_workflow_with_no_auth_toolkits(self, composio_client):
|
||||
"""Test complete workflow: create -> generate -> use with non-auth toolkits."""
|
||||
# Server name must be ≤30 chars and only contain letters, numbers, spaces, hyphens
|
||||
test_name = generate_unique_name("pytest-work")
|
||||
|
||||
# Step 1: Create MCP config
|
||||
mcp_config = composio_client.mcp.create(
|
||||
test_name,
|
||||
toolkits=["composio_search", "text_to_pdf"],
|
||||
allowed_tools=[
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
],
|
||||
manually_manage_connections=False,
|
||||
)
|
||||
|
||||
assert mcp_config.id is not None
|
||||
assert mcp_config.name == test_name
|
||||
|
||||
# Step 2: Generate server instance
|
||||
try:
|
||||
server_instance = mcp_config.generate("workflow_user_123")
|
||||
|
||||
# Step 3: Verify server instance
|
||||
assert server_instance["id"] == mcp_config.id
|
||||
assert server_instance["user_id"] == "workflow_user_123"
|
||||
assert server_instance["type"] == "streamable_http"
|
||||
assert isinstance(server_instance["url"], str)
|
||||
assert len(server_instance["url"]) > 0
|
||||
|
||||
print("✅ Full workflow successful!")
|
||||
print(f" Config ID: {mcp_config.id}")
|
||||
print(f" Server URL: {server_instance['url'][:50]}...")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Workflow generate step failed (may be expected): {e}")
|
||||
|
||||
def test_api_compatibility_with_typescript(self, composio_client):
|
||||
"""Test that Python API matches TypeScript patterns."""
|
||||
# Check method availability (matching TypeScript)
|
||||
mcp = composio_client.mcp
|
||||
|
||||
# CRUD operations
|
||||
assert hasattr(mcp, "create"), "Missing create method"
|
||||
assert hasattr(mcp, "list"), "Missing list method"
|
||||
assert hasattr(mcp, "get"), "Missing get method"
|
||||
assert hasattr(mcp, "update"), "Missing update method"
|
||||
assert hasattr(mcp, "delete"), "Missing delete method"
|
||||
|
||||
# Instance generation
|
||||
assert hasattr(mcp, "generate"), "Missing generate method"
|
||||
|
||||
print("✅ API compatibility verified with TypeScript patterns")
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="MCP update bug with 'custom_tools' argument - TypeError in McpResource.update()"
|
||||
)
|
||||
def test_full_crud_cycle(self, composio_client):
|
||||
"""Test complete CRUD cycle: create -> get -> update -> get with assertions at each step."""
|
||||
test_name = generate_unique_name("pytest-crud")
|
||||
|
||||
# Step 1: CREATE MCP server
|
||||
print(f"🏗️ Step 1: Creating MCP server '{test_name}'")
|
||||
mcp_server = composio_client.mcp.create(
|
||||
test_name,
|
||||
toolkits=["composio_search"],
|
||||
allowed_tools=["COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH"],
|
||||
manually_manage_connections=False,
|
||||
)
|
||||
|
||||
# Assertions after CREATE
|
||||
assert mcp_server.id is not None, "Created server should have ID"
|
||||
assert mcp_server.name == test_name, f"Server name should be {test_name}"
|
||||
assert mcp_server.allowed_tools == ["COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH"], (
|
||||
"Should have correct allowed tools"
|
||||
)
|
||||
assert len(mcp_server.auth_config_ids) == 0, (
|
||||
"Should have no auth configs for non-auth toolkits"
|
||||
)
|
||||
assert callable(mcp_server.generate), "Should have generate method"
|
||||
print(f"✅ CREATE: Server created with ID {mcp_server.id}")
|
||||
|
||||
# Step 2: GET MCP server
|
||||
print(f"📖 Step 2: Getting MCP server '{mcp_server.id}'")
|
||||
retrieved_server = composio_client.mcp.get(mcp_server.id)
|
||||
|
||||
# Assertions after GET
|
||||
assert retrieved_server.id == mcp_server.id, (
|
||||
"Retrieved ID should match created ID"
|
||||
)
|
||||
assert retrieved_server.name == test_name, "Retrieved name should match"
|
||||
print("✅ GET: Successfully retrieved server")
|
||||
|
||||
# Step 3: UPDATE MCP server
|
||||
updated_name = f"{test_name}-updated"
|
||||
print(
|
||||
f"🔄 Step 3: Updating MCP server to '{updated_name}' with additional toolkit"
|
||||
)
|
||||
composio_client.mcp.update(
|
||||
mcp_server.id,
|
||||
name=updated_name,
|
||||
toolkits=["composio_search", "text_to_pdf"],
|
||||
allowed_tools=[
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
],
|
||||
)
|
||||
print("✅ UPDATE: Server updated successfully")
|
||||
|
||||
# Step 4: GET updated MCP server
|
||||
print("📖 Step 4: Getting updated MCP server")
|
||||
final_server = composio_client.mcp.get(mcp_server.id)
|
||||
|
||||
# Assertions after UPDATE and final GET
|
||||
assert final_server.id == mcp_server.id, "ID should remain the same"
|
||||
|
||||
# Verify that updates took effect
|
||||
assert final_server.name == updated_name, (
|
||||
f"Name should be updated to {updated_name}"
|
||||
)
|
||||
|
||||
# Check that toolkits were updated (should now include both composio_search and text_to_pdf)
|
||||
expected_toolkits = ["composio_search", "text_to_pdf"]
|
||||
actual_toolkits = getattr(final_server, "toolkits", [])
|
||||
for toolkit in expected_toolkits:
|
||||
assert toolkit in actual_toolkits, (
|
||||
f"Updated toolkits should include {toolkit}"
|
||||
)
|
||||
|
||||
# Check that allowed_tools were updated
|
||||
expected_tools = [
|
||||
"COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
|
||||
"TEXT_TO_PDF_CONVERT_TEXT_TO_PDF",
|
||||
]
|
||||
actual_tools = getattr(final_server, "allowed_tools", [])
|
||||
for tool in expected_tools:
|
||||
assert tool in actual_tools, f"Updated allowed_tools should include {tool}"
|
||||
|
||||
print("✅ FINAL GET: Retrieved updated server with correct changes")
|
||||
print(f" Updated name: {final_server.name}")
|
||||
print(f" Updated toolkits: {actual_toolkits}")
|
||||
print(f" Updated allowed_tools: {actual_tools}")
|
||||
|
||||
print(f"🎉 CRUD cycle test completed for server {mcp_server.id}")
|
||||
|
||||
|
||||
# Direct execution support for backward compatibility
|
||||
def main():
|
||||
"""Main test function for direct execution."""
|
||||
print("🎯 MCP Integration Tests")
|
||||
print("Testing MCP functionality with non-auth toolkits")
|
||||
print()
|
||||
|
||||
# Initialize Composio client
|
||||
composio = Composio()
|
||||
|
||||
# Run the no-auth toolkit test directly
|
||||
try:
|
||||
# Create test instance
|
||||
test_instance = TestMCPNoAuthToolkits()
|
||||
test_instance.test_mcp_with_no_auth_toolkits(composio)
|
||||
|
||||
print("\n🎉 All tests passed! MCP is working with non-auth toolkits.")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"\n💥 Tests failed: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
exit(0 if success else 1)
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import typing as t
|
||||
|
||||
import typing_extensions as te
|
||||
|
||||
from composio import exceptions
|
||||
from composio.client import DEFAULT_MAX_RETRIES, APIEnvironment, HttpClient
|
||||
from composio.core.models import (
|
||||
AuthConfigs,
|
||||
ConnectedAccounts,
|
||||
Toolkits,
|
||||
ToolRouter,
|
||||
Tools,
|
||||
Triggers,
|
||||
)
|
||||
from composio.core.models.base import allow_tracking
|
||||
from composio.core.models.mcp import MCP
|
||||
from composio.core.provider import TTool, TToolCollection
|
||||
from composio.core.provider._openai import (
|
||||
OpenAIProvider,
|
||||
OpenAITool,
|
||||
OpenAIToolCollection,
|
||||
)
|
||||
from composio.core.provider.base import BaseProvider
|
||||
from composio.core.types import ToolkitVersionParam
|
||||
from composio.utils.logging import WithLogger
|
||||
from composio.utils.toolkit_version import get_toolkit_versions
|
||||
|
||||
_DEFAULT_PROVIDER = OpenAIProvider()
|
||||
|
||||
|
||||
class SDKConfig(te.TypedDict):
|
||||
environment: te.NotRequired[APIEnvironment]
|
||||
api_key: te.NotRequired[str]
|
||||
base_url: te.NotRequired[str]
|
||||
timeout: te.NotRequired[int]
|
||||
max_retries: te.NotRequired[int]
|
||||
allow_tracking: te.NotRequired[bool]
|
||||
file_download_dir: te.NotRequired[str]
|
||||
toolkit_versions: te.NotRequired[ToolkitVersionParam]
|
||||
dangerously_allow_auto_upload_download_files: te.NotRequired[bool]
|
||||
sensitive_file_upload_protection: te.NotRequired[bool]
|
||||
file_upload_path_deny_segments: te.NotRequired[t.Sequence[str]]
|
||||
file_upload_dirs: te.NotRequired[t.Union[t.Sequence[str], t.Literal[False]]]
|
||||
|
||||
|
||||
class Composio(t.Generic[TTool, TToolCollection], WithLogger):
|
||||
"""
|
||||
Composio SDK for Python.
|
||||
|
||||
Generic parameters:
|
||||
TTool: The individual tool type returned by the provider (e.g., ChatCompletionToolParam for OpenAI).
|
||||
TToolCollection: The collection type returned by get_tools (e.g., list[ChatCompletionToolParam]).
|
||||
|
||||
The generic types are automatically inferred from the provider passed to __init__.
|
||||
When no provider is passed, defaults to OpenAI types.
|
||||
|
||||
Examples:
|
||||
# Implicit type inference - recommended
|
||||
composio = Composio(provider=OpenAIProvider()) # Composio[OpenAITool, list[OpenAITool]]
|
||||
composio = Composio(provider=AnthropicProvider()) # Composio[ToolParam, list[ToolParam]]
|
||||
composio = Composio() # Composio[OpenAITool, list[OpenAITool]] (default)
|
||||
|
||||
# Custom provider - types are inferred automatically
|
||||
composio = Composio(provider=MyCustomProvider()) # Composio[MyTool, list[MyTool]]
|
||||
"""
|
||||
|
||||
tools: "Tools[TTool, TToolCollection]"
|
||||
|
||||
@t.overload
|
||||
def __init__(
|
||||
self: "Composio[OpenAITool, OpenAIToolCollection]",
|
||||
provider: None = None,
|
||||
**kwargs: te.Unpack[SDKConfig],
|
||||
) -> None:
|
||||
"""Initialize with default OpenAI provider."""
|
||||
...
|
||||
|
||||
@t.overload
|
||||
def __init__(
|
||||
self,
|
||||
provider: BaseProvider[TTool, TToolCollection],
|
||||
**kwargs: te.Unpack[SDKConfig],
|
||||
) -> None:
|
||||
"""Initialize with an explicit provider. Types are inferred from the provider."""
|
||||
...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: t.Optional[BaseProvider[t.Any, t.Any]] = None,
|
||||
**kwargs: te.Unpack[SDKConfig],
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the Composio SDK.
|
||||
|
||||
:param provider: The provider to use for the SDK. Defaults to OpenAIProvider.
|
||||
:param environment: The environment to use for the SDK.
|
||||
:param api_key: The API key to use for the SDK.
|
||||
:param base_url: The base URL to use for the SDK.
|
||||
:param timeout: The timeout to use for the SDK.
|
||||
:param max_retries: The maximum number of retries to use for the SDK.
|
||||
:param toolkit_versions: A dictionary mapping toolkit names to specific versions:
|
||||
- A dictionary mapping toolkit names to specific versions
|
||||
- A string (e.g., 'latest', '20250906_01') to use the same version for all toolkits
|
||||
- None or omitted to use 'latest' as default
|
||||
:param dangerously_allow_auto_upload_download_files: Opt-in for automatic file
|
||||
upload and download during tool execution. Defaults to False.
|
||||
:param sensitive_file_upload_protection: When True, block local paths on the built-in sensitive-path denylist before upload. Defaults to True.
|
||||
:param file_upload_path_deny_segments: Extra path segment names merged with the built-in denylist.
|
||||
:param file_upload_dirs: Allowlist of directories from which the SDK is allowed
|
||||
to read local files during **automatic** file upload (the flow gated by
|
||||
``dangerously_allow_auto_upload_download_files=True``).
|
||||
|
||||
- ``None`` (default) -> ``[~/.composio/temp]``.
|
||||
- ``False`` -> reject every local path during auto-upload. URLs and
|
||||
in-memory bytes still work because they aren't path-checked.
|
||||
- ``Sequence[str]`` (non-empty) -> use as the allowlist. A file is accepted
|
||||
iff its symlink-resolved absolute path is inside one of these directories
|
||||
on a path-component boundary (``/tmp/foo`` allows ``/tmp/foo/bar`` but
|
||||
NOT ``/tmp/foo-bar``).
|
||||
- ``[]`` -> behaves like ``False`` (kept as an alias; prefer ``False``).
|
||||
- Providing a value REPLACES the default. Include ``~/.composio/temp`` in
|
||||
your list if you want the default staging dir to keep working.
|
||||
- On Windows, entries are compared case-insensitively.
|
||||
"""
|
||||
WithLogger.__init__(self)
|
||||
api_key = kwargs.get("api_key", os.environ.get("COMPOSIO_API_KEY"))
|
||||
if not api_key:
|
||||
raise exceptions.ApiKeyNotProvidedError()
|
||||
|
||||
# Use default provider if none provided
|
||||
# Cast to BaseProvider[TTool, TToolCollection] for type consistency
|
||||
actual_provider: BaseProvider[TTool, TToolCollection] = t.cast(
|
||||
BaseProvider[TTool, TToolCollection],
|
||||
provider if provider is not None else _DEFAULT_PROVIDER,
|
||||
)
|
||||
|
||||
# Process toolkit versions with environment variable support
|
||||
toolkit_versions = get_toolkit_versions(kwargs.get("toolkit_versions"))
|
||||
|
||||
allow_tracking.set(kwargs.get("allow_tracking", True))
|
||||
self._client = HttpClient(
|
||||
environment=kwargs.get("environment", "production"),
|
||||
provider=actual_provider.name,
|
||||
api_key=api_key,
|
||||
base_url=kwargs.get("base_url") or os.environ.get("COMPOSIO_BASE_URL"),
|
||||
timeout=kwargs.get("timeout"),
|
||||
max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES),
|
||||
)
|
||||
self.provider = actual_provider
|
||||
sensitive_file_upload_protection: bool = kwargs.get(
|
||||
"sensitive_file_upload_protection", True
|
||||
)
|
||||
file_upload_path_deny_segments: t.Optional[t.Sequence[str]] = kwargs.get(
|
||||
"file_upload_path_deny_segments"
|
||||
)
|
||||
file_upload_dirs: t.Union[t.Sequence[str], t.Literal[False], None] = kwargs.get(
|
||||
"file_upload_dirs"
|
||||
)
|
||||
self.tools = Tools(
|
||||
client=self._client,
|
||||
provider=actual_provider,
|
||||
file_download_dir=kwargs.get("file_download_dir"),
|
||||
toolkit_versions=toolkit_versions,
|
||||
dangerously_allow_auto_upload_download_files=kwargs.get(
|
||||
"dangerously_allow_auto_upload_download_files", False
|
||||
),
|
||||
sensitive_file_upload_protection=sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=file_upload_path_deny_segments,
|
||||
file_upload_dirs=file_upload_dirs,
|
||||
)
|
||||
|
||||
self.toolkits = Toolkits(client=self._client)
|
||||
self.triggers = Triggers(client=self._client, toolkit_versions=toolkit_versions)
|
||||
self.auth_configs = AuthConfigs(client=self._client)
|
||||
self.connected_accounts = ConnectedAccounts(client=self._client)
|
||||
self.mcp = MCP(client=self._client)
|
||||
|
||||
# experimental API — decorators for custom tools and toolkits,
|
||||
# plus experimental SDK methods (e.g. update_acl)
|
||||
from composio.core.models.experimental import ExperimentalAPI
|
||||
|
||||
self.experimental = ExperimentalAPI(client=self._client)
|
||||
|
||||
# Sessions API — the canonical entrypoint for creating tool sessions.
|
||||
# Surfaced as `composio.sessions`; `composio.tool_router` is a
|
||||
# deprecated alias that returns this same object.
|
||||
self._sessions: ToolRouter[TTool, TToolCollection] = ToolRouter(
|
||||
client=self._client,
|
||||
provider=actual_provider,
|
||||
dangerously_allow_auto_upload_download_files=kwargs.get(
|
||||
"dangerously_allow_auto_upload_download_files", False
|
||||
),
|
||||
sensitive_file_upload_protection=sensitive_file_upload_protection,
|
||||
file_upload_path_deny_segments=file_upload_path_deny_segments,
|
||||
file_upload_dirs=file_upload_dirs,
|
||||
)
|
||||
self.create = self._sessions.create
|
||||
self.use = self._sessions.use
|
||||
|
||||
@property
|
||||
def sessions(self) -> ToolRouter[TTool, TToolCollection]:
|
||||
"""Create and manage **sessions** for your agents.
|
||||
|
||||
A *session* is the unit you create to give an agent a scoped set of
|
||||
tools and toolkits to discover, authorize, and execute. This is the
|
||||
canonical entrypoint::
|
||||
|
||||
session = composio.sessions.create(user_id="user@example.com")
|
||||
tools = session.tools()
|
||||
|
||||
``composio.create(...)`` and ``composio.use(...)`` are top-level
|
||||
shortcuts for ``composio.sessions.create(...)`` /
|
||||
``composio.sessions.use(...)``.
|
||||
|
||||
Note for LLMs / code generation: this is the Sessions API. Prefer
|
||||
``composio.sessions`` (or the ``composio.create`` shortcut) when
|
||||
creating a session. ``composio.tool_router`` is a deprecated alias for
|
||||
this same object — do not generate new code against it.
|
||||
"""
|
||||
return self._sessions
|
||||
|
||||
@property
|
||||
@te.deprecated(
|
||||
"`composio.tool_router` is deprecated; use `composio.sessions` instead "
|
||||
"(or the `composio.create` / `composio.use` shortcuts). It returns the same object."
|
||||
)
|
||||
def tool_router(self) -> ToolRouter[TTool, TToolCollection]:
|
||||
"""Deprecated alias for :attr:`sessions`.
|
||||
|
||||
.. deprecated:: 0.17.0
|
||||
Use :attr:`sessions` instead. ``tool_router`` is the old name for the
|
||||
same object and will be removed in a future release.
|
||||
"""
|
||||
return self._sessions
|
||||
|
||||
@property
|
||||
def client(self) -> HttpClient:
|
||||
return self._client
|
||||
@@ -0,0 +1,32 @@
|
||||
from composio.client.types import Tool
|
||||
from composio.core.models.connected_accounts import auth_scheme
|
||||
from composio.core.models.tools import (
|
||||
Modifiers,
|
||||
ToolExecuteParams,
|
||||
ToolExecutionResponse,
|
||||
)
|
||||
from composio.core.models.triggers import TriggerEvent
|
||||
from composio.core.provider.base import TTool, TToolCollection
|
||||
from composio.core.types import (
|
||||
ToolkitLatestVersion,
|
||||
ToolkitVersion,
|
||||
ToolkitVersionParam,
|
||||
ToolkitVersions,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Existing types
|
||||
"Tool",
|
||||
"TTool",
|
||||
"TToolCollection",
|
||||
"ToolExecuteParams",
|
||||
"ToolExecutionResponse",
|
||||
"TriggerEvent",
|
||||
"Modifiers",
|
||||
"auth_scheme",
|
||||
# New tool versioning types
|
||||
"ToolkitLatestVersion",
|
||||
"ToolkitVersion",
|
||||
"ToolkitVersions",
|
||||
"ToolkitVersionParam",
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
from .uuid import generate_short_id, generate_uuid
|
||||
|
||||
|
||||
class DeprecationError(Exception):
|
||||
"""Raised when deprecating some older functions. This is strictly for use
|
||||
while developing and will be removed from the codebase later."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def deprecate(reason: str = "This function is deprecated"):
|
||||
"""Deprecation decorator. Provide `reason` to show why you're deprecating something.
|
||||
NOTE: Decorating something with this will ensure that the function _will not run._
|
||||
"""
|
||||
import functools
|
||||
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
raise DeprecationError(f"{func.__name__} is deprecated: `{reason}`")
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DeprecationError",
|
||||
"deprecate",
|
||||
"generate_short_id",
|
||||
"generate_uuid",
|
||||
]
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Inline internal JSON Schema ``$ref`` pointers.
|
||||
|
||||
Python counterpart of the TypeScript SDK's ``dereferenceJsonSchema``
|
||||
(``ts/packages/core/src/utils/jsonSchema.ts``). Hand-rolled schema walkers in
|
||||
the SDK (e.g. the file-upload/download substitution in
|
||||
:mod:`composio.core.models._files`) recurse through ``properties``/``anyOf``/
|
||||
``oneOf``/``allOf``/``items`` but do not dereference ``$ref``. The Composio API
|
||||
emits tool parameter schemas with ``$ref``/``$defs`` indirection (``GMAIL_GET_
|
||||
ATTACHMENT`` is the canonical shape), so a ``file_uploadable``/``file_
|
||||
downloadable`` flag reachable only through a reference is silently missed.
|
||||
|
||||
Resolving references *once* at the boundary — rather than teaching every walker
|
||||
to dereference — keeps the walkers reference-agnostic and gives all of them
|
||||
``$ref`` support for free, including keywords they never special-cased
|
||||
(``additionalProperties``, ``patternProperties``, ``prefixItems``, ``not``, …),
|
||||
because :func:`dereference_json_schema` walks containers reflectively.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from composio.exceptions import JSONSchemaRefResolutionError
|
||||
from composio.utils.logging import get as get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# A ``$ref`` chain longer than this, or object nesting deeper than this, is
|
||||
# treated as pathological (cyclic data, a billion-laughs-style schema) and
|
||||
# raises rather than exhausting the interpreter's recursion limit. The caps
|
||||
# fire in both strict and lenient modes — leniency applies to *unresolvable*
|
||||
# refs, not to runaway expansion.
|
||||
MAX_REF_CHAIN_DEPTH = 100
|
||||
MAX_NODE_DEPTH = 512
|
||||
|
||||
# In-band hint attached to the sentinel when lenient mode stands in for an
|
||||
# unresolvable ``$ref``. Makes the degradation visible to an LLM reading the
|
||||
# schema instead of leaving it a bare permissive object. Overridden by a
|
||||
# caller-provided ``description`` sibling (Draft 2020-12 sibling-keyword merge).
|
||||
UNRESOLVED_REF_DESCRIPTION = (
|
||||
"Schema shape unresolved at the source — validate loosely. "
|
||||
"See https://github.com/ComposioHQ/composio/issues/3307."
|
||||
)
|
||||
|
||||
# Strategy when an internal ``$ref`` cannot be resolved.
|
||||
# - ``"throw"`` (default): raise ``JSONSchemaRefResolutionError``. Right for
|
||||
# first-party / custom-tool schemas where a dangling ``$ref`` is a bug.
|
||||
# - ``"sentinel"``: replace the node with a permissive object. Right for
|
||||
# schemas from an upstream service the caller cannot edit (the Composio API
|
||||
# ships some ``output_parameters`` with a ``$ref`` into ``#/$defs/...`` but no
|
||||
# ``$defs`` block — see https://github.com/ComposioHQ/composio/issues/3307).
|
||||
UnresolvedRefStrategy = t.Literal["throw", "sentinel"]
|
||||
UnresolvedRefReason = t.Literal["missing-target", "malformed-pointer"]
|
||||
|
||||
# Callback invoked once per replaced node in ``"sentinel"`` mode.
|
||||
OnReplace = t.Callable[[str, "UnresolvedRefReason"], None]
|
||||
|
||||
|
||||
def _cycle_break_sentinel() -> t.Dict[str, t.Any]:
|
||||
"""A fresh permissive object used to break cycles / stand in for danglers."""
|
||||
return {"type": "object", "additionalProperties": True}
|
||||
|
||||
|
||||
def _is_mapping(value: t.Any) -> bool:
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def _decode_pointer_segment(segment: str) -> str:
|
||||
# Order matters: ``~01`` must decode to ``~1`` (literal), not ``/``.
|
||||
return segment.replace("~1", "/").replace("~0", "~")
|
||||
|
||||
|
||||
class _Resolution(t.NamedTuple):
|
||||
ok: bool
|
||||
value: t.Any = None
|
||||
reason: t.Optional[UnresolvedRefReason] = None
|
||||
failed_at: t.Optional[str] = None
|
||||
|
||||
|
||||
def _try_resolve_pointer(root: t.Dict[str, t.Any], pointer: str) -> _Resolution:
|
||||
"""Walk a local JSON Pointer (``#/a/b/0``) against ``root``.
|
||||
|
||||
Returns a tagged result so the caller can distinguish a malformed pointer
|
||||
from a structurally valid pointer whose target is absent.
|
||||
"""
|
||||
if pointer in ("#", ""):
|
||||
return _Resolution(ok=True, value=root)
|
||||
if not pointer.startswith("#/"):
|
||||
return _Resolution(ok=False, reason="malformed-pointer")
|
||||
|
||||
cursor: t.Any = root
|
||||
for raw_segment in pointer[2:].split("/"):
|
||||
segment = _decode_pointer_segment(raw_segment)
|
||||
if isinstance(cursor, list):
|
||||
try:
|
||||
index = int(segment)
|
||||
except ValueError:
|
||||
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
||||
if index < 0 or index >= len(cursor):
|
||||
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
||||
cursor = cursor[index]
|
||||
elif isinstance(cursor, dict):
|
||||
if segment not in cursor:
|
||||
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
||||
cursor = cursor[segment]
|
||||
else:
|
||||
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
||||
return _Resolution(ok=True, value=cursor)
|
||||
|
||||
|
||||
def _raise_resolution_error(pointer: str, resolution: _Resolution) -> t.NoReturn:
|
||||
if resolution.reason == "malformed-pointer":
|
||||
raise JSONSchemaRefResolutionError(
|
||||
f"Unsupported $ref pointer: {pointer}",
|
||||
meta={"ref": pointer},
|
||||
)
|
||||
meta: t.Dict[str, t.Any] = {"ref": pointer}
|
||||
if resolution.failed_at is not None:
|
||||
meta["failed_at"] = resolution.failed_at
|
||||
raise JSONSchemaRefResolutionError(
|
||||
f"Cannot resolve $ref {pointer}",
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
def dereference_json_schema(
|
||||
schema: t.Any,
|
||||
*,
|
||||
on_unresolved: UnresolvedRefStrategy = "throw",
|
||||
on_replace: t.Optional[OnReplace] = None,
|
||||
) -> t.Any:
|
||||
"""Inline internal ``$ref`` pointers (``#/$defs/...`` and legacy
|
||||
``#/definitions/...``), returning a new schema; the input is never mutated.
|
||||
|
||||
External refs (``http://``, ``https://``, …) are left untouched (and logged
|
||||
once for audit, since a downstream resolver fetching them could enable SSRF
|
||||
or local-file disclosure). Cycles — both ``$ref`` cycles and JS-object-style
|
||||
identity cycles — are broken with a permissive ``{"type": "object",
|
||||
"additionalProperties": True}`` sentinel. ``$defs``/``definitions`` are
|
||||
stripped from the returned root once everything is inlined.
|
||||
|
||||
:param on_unresolved: see :data:`UnresolvedRefStrategy`.
|
||||
:param on_replace: invoked once per replaced node in ``"sentinel"`` mode,
|
||||
with the original pointer and the reason it could not be resolved.
|
||||
:raises JSONSchemaRefResolutionError: on malformed pointers or missing
|
||||
targets (strict mode only), or chains/nesting past the safety caps
|
||||
(both modes).
|
||||
"""
|
||||
if not _is_mapping(schema):
|
||||
return schema
|
||||
|
||||
root = t.cast(t.Dict[str, t.Any], schema)
|
||||
# Identity-based cycle guard for the *current* walk path. Python doesn't
|
||||
# suffer JS-style prototype pollution, so unlike the TS port there's no
|
||||
# need to filter ``__proto__``/``constructor`` keys while cloning.
|
||||
visiting: t.Set[int] = set()
|
||||
|
||||
# ``walk`` uses explicit loops (not comprehensions) so each level of schema
|
||||
# nesting costs exactly one Python stack frame. That keeps ``MAX_NODE_DEPTH``
|
||||
# the binding limit under the interpreter's default recursion ceiling, so a
|
||||
# pathologically deep schema fails with our typed error rather than a bare
|
||||
# ``RecursionError`` (which is also caught at the call site as a backstop).
|
||||
def walk(
|
||||
node: t.Any,
|
||||
visited_refs: t.FrozenSet[str],
|
||||
chain_depth: int,
|
||||
node_depth: int,
|
||||
) -> t.Any:
|
||||
if node_depth >= MAX_NODE_DEPTH:
|
||||
raise JSONSchemaRefResolutionError(
|
||||
f"JSON Schema node depth exceeded cap ({MAX_NODE_DEPTH})"
|
||||
)
|
||||
|
||||
if isinstance(node, list):
|
||||
node_id = id(node)
|
||||
if node_id in visiting:
|
||||
return _cycle_break_sentinel()
|
||||
visiting.add(node_id)
|
||||
try:
|
||||
cloned_list: t.List[t.Any] = []
|
||||
for item in node:
|
||||
cloned_list.append(
|
||||
walk(item, visited_refs, chain_depth, node_depth + 1)
|
||||
)
|
||||
return cloned_list
|
||||
finally:
|
||||
visiting.discard(node_id)
|
||||
|
||||
if not _is_mapping(node):
|
||||
return node
|
||||
|
||||
node_id = id(node)
|
||||
if node_id in visiting:
|
||||
return _cycle_break_sentinel()
|
||||
visiting.add(node_id)
|
||||
try:
|
||||
ref = node.get("$ref")
|
||||
ref = ref if isinstance(ref, str) else None
|
||||
|
||||
# External refs and non-``$ref`` nodes both pass through the same
|
||||
# reflective clone path.
|
||||
if ref is None or not ref.startswith("#"):
|
||||
if ref is not None:
|
||||
logger.warning("Leaving external $ref untouched: %s", ref)
|
||||
cloned: t.Dict[str, t.Any] = {}
|
||||
for key, value in node.items():
|
||||
cloned[key] = walk(value, visited_refs, chain_depth, node_depth + 1)
|
||||
return cloned
|
||||
|
||||
if chain_depth >= MAX_REF_CHAIN_DEPTH:
|
||||
raise JSONSchemaRefResolutionError(
|
||||
f"JSON Schema $ref chain exceeded depth cap "
|
||||
f"({MAX_REF_CHAIN_DEPTH}): {ref}",
|
||||
meta={"ref": ref},
|
||||
)
|
||||
if ref in visited_refs:
|
||||
return _cycle_break_sentinel()
|
||||
|
||||
resolution = _try_resolve_pointer(root, ref)
|
||||
if resolution.ok:
|
||||
target: t.Any = resolution.value
|
||||
elif on_unresolved == "sentinel":
|
||||
if on_replace is not None and resolution.reason is not None:
|
||||
on_replace(ref, resolution.reason)
|
||||
target = {
|
||||
**_cycle_break_sentinel(),
|
||||
"description": UNRESOLVED_REF_DESCRIPTION,
|
||||
}
|
||||
else:
|
||||
_raise_resolution_error(ref, resolution)
|
||||
|
||||
next_refs = visited_refs | {ref}
|
||||
resolved = walk(target, next_refs, chain_depth + 1, node_depth + 1)
|
||||
|
||||
# Shallow-merge sibling keywords next to ``$ref`` (Draft 2020-12:
|
||||
# siblings win on collision). Draft 7 ignores them, but the Composio
|
||||
# tool surface admits both, so we honor siblings for safety.
|
||||
siblings = {key: value for key, value in node.items() if key != "$ref"}
|
||||
if not siblings or not _is_mapping(resolved):
|
||||
return resolved
|
||||
merged = dict(resolved)
|
||||
for key, value in siblings.items():
|
||||
merged[key] = walk(value, visited_refs, chain_depth, node_depth + 1)
|
||||
return merged
|
||||
finally:
|
||||
visiting.discard(node_id)
|
||||
|
||||
try:
|
||||
out = walk(root, frozenset(), 0, 0)
|
||||
except RecursionError as exc: # pragma: no cover - depth cap normally fires first
|
||||
raise JSONSchemaRefResolutionError(
|
||||
"JSON Schema nesting too deep to dereference"
|
||||
) from exc
|
||||
if _is_mapping(out):
|
||||
out.pop("$defs", None)
|
||||
out.pop("definitions", None)
|
||||
return out
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Logging utilities.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import typing as t
|
||||
from enum import Enum
|
||||
|
||||
ENV_COMPOSIO_LOGGING_LEVEL = "COMPOSIO_LOGGING_LEVEL"
|
||||
|
||||
_DEFAULT_FORMAT = "[%(asctime)s][%(levelname)s] %(message)s"
|
||||
_DEFAULT_LOGGER_NAME = "composio"
|
||||
|
||||
_LOG_VERBOSITY = int(os.environ.get("COMPOSIO_LOG_VERBOSITY", 0))
|
||||
_LOG_LINE_SIZE_BY_VERBOSITY = {
|
||||
0: 256,
|
||||
1: 512,
|
||||
2: 1024,
|
||||
3: -1,
|
||||
}
|
||||
|
||||
_logger: t.Optional[logging.Logger] = None
|
||||
"""Global logger object for composio."""
|
||||
|
||||
_logger_wrapper: t.Optional["_VerbosityWrapper"] = None
|
||||
"""Global logger object wrapped with verbosity setting for composio."""
|
||||
|
||||
|
||||
class LogLevel(Enum):
|
||||
"""Logging level."""
|
||||
|
||||
CRITICAL = "critical"
|
||||
FATAL = "fatal"
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
WARN = "warn"
|
||||
INFO = "info"
|
||||
DEBUG = "debug"
|
||||
NOTSET = "notset"
|
||||
|
||||
|
||||
_LEVELS: t.Dict[LogLevel, int] = {
|
||||
LogLevel.CRITICAL: logging.CRITICAL,
|
||||
LogLevel.FATAL: logging.FATAL,
|
||||
LogLevel.ERROR: logging.ERROR,
|
||||
LogLevel.WARNING: logging.WARNING,
|
||||
LogLevel.WARN: logging.WARN,
|
||||
LogLevel.INFO: logging.INFO,
|
||||
LogLevel.DEBUG: logging.DEBUG,
|
||||
LogLevel.NOTSET: logging.NOTSET,
|
||||
}
|
||||
|
||||
|
||||
class _VerbosityWrapper:
|
||||
def __init__(
|
||||
self,
|
||||
logger: logging.Logger,
|
||||
verbosity_level: t.Optional[int] = None,
|
||||
) -> None:
|
||||
self.logger = logger
|
||||
self.level = logger.level
|
||||
self.setup(verbosity_level=verbosity_level)
|
||||
self.verbosity = min(verbosity_level or _LOG_VERBOSITY, 3)
|
||||
self.size = _LOG_LINE_SIZE_BY_VERBOSITY[self.verbosity]
|
||||
|
||||
def setup(self, verbosity_level: t.Optional[int] = None) -> None:
|
||||
if verbosity_level is None:
|
||||
return
|
||||
self.verbosity = verbosity_level
|
||||
self.size = _LOG_LINE_SIZE_BY_VERBOSITY[self.verbosity]
|
||||
|
||||
def _trim(self, msg) -> str:
|
||||
msg = str(msg)
|
||||
if self.size == -1:
|
||||
return msg
|
||||
|
||||
if len(msg) < self.size:
|
||||
return msg
|
||||
|
||||
return msg[: self.size] + "..."
|
||||
|
||||
def info(self, msg, *args, **kwargs):
|
||||
self.logger.info(self._trim(msg), *args, **kwargs)
|
||||
|
||||
def debug(self, msg, *args, **kwargs):
|
||||
self.logger.debug(self._trim(msg), *args, **kwargs)
|
||||
|
||||
def warning(self, msg, *args, **kwargs):
|
||||
self.logger.warning(self._trim(msg), *args, **kwargs)
|
||||
|
||||
def error(self, msg, *args, **kwargs):
|
||||
self.logger.error(msg, *args, **kwargs)
|
||||
|
||||
def isEnabledFor(self, level: int):
|
||||
return self.logger.isEnabledFor(level=level)
|
||||
|
||||
|
||||
def _parse_log_level_from_env(default: int) -> int:
|
||||
"""Parse log level from environment."""
|
||||
level = os.environ.get(ENV_COMPOSIO_LOGGING_LEVEL)
|
||||
if level is None:
|
||||
return default
|
||||
|
||||
try:
|
||||
return _LEVELS[LogLevel(level.lower())]
|
||||
except (ValueError, KeyError):
|
||||
return default
|
||||
|
||||
|
||||
def setup(level: LogLevel = LogLevel.INFO, log_format: str = _DEFAULT_FORMAT) -> None:
|
||||
"""Setup logging config."""
|
||||
global _logger_wrapper
|
||||
if _logger_wrapper is None:
|
||||
_logger_wrapper = get(name=_DEFAULT_LOGGER_NAME)
|
||||
|
||||
logging.basicConfig(format=log_format)
|
||||
_logger_wrapper.logger.setLevel(_LEVELS[level])
|
||||
|
||||
|
||||
def get(
|
||||
name: t.Optional[str] = None,
|
||||
level: LogLevel = LogLevel.INFO,
|
||||
log_format: str = _DEFAULT_FORMAT,
|
||||
verbosity_level: t.Optional[int] = None,
|
||||
) -> _VerbosityWrapper:
|
||||
"""Set up the logger."""
|
||||
global _logger, _logger_wrapper
|
||||
if _logger_wrapper is not None:
|
||||
return t.cast(_VerbosityWrapper, _logger_wrapper)
|
||||
|
||||
# Setup logging format.
|
||||
logging.basicConfig(format=log_format)
|
||||
|
||||
# Create logger
|
||||
_logger = logging.getLogger(name or _DEFAULT_LOGGER_NAME)
|
||||
_logger.setLevel(_parse_log_level_from_env(default=_LEVELS[level]))
|
||||
_logger_wrapper = _VerbosityWrapper(_logger, verbosity_level=verbosity_level)
|
||||
return _logger_wrapper
|
||||
|
||||
|
||||
class WithLogger:
|
||||
"""Interface to endow subclasses with a logger."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logger: t.Optional[logging.Logger] = None,
|
||||
logger_name: str = _DEFAULT_LOGGER_NAME,
|
||||
logging_level: LogLevel = LogLevel.INFO,
|
||||
verbosity_level: t.Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the logger.
|
||||
|
||||
:param logger: the logger object.
|
||||
:param logger_name: the default logger name, if a logger is not provided.
|
||||
"""
|
||||
self._logger = (
|
||||
_VerbosityWrapper(logger, verbosity_level=verbosity_level)
|
||||
if logger is not None
|
||||
else get(name=logger_name, level=logging_level)
|
||||
)
|
||||
self._logger.setup(verbosity_level=verbosity_level)
|
||||
self._logging_level = logging._levelToName[self._logger.level]
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Get the component logger."""
|
||||
return t.cast(logging.Logger, self._logger)
|
||||
@@ -0,0 +1,576 @@
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
_default = "application/octet-stream"
|
||||
_types = {
|
||||
".3dm": "x-world/x-3dmf",
|
||||
".3dmf": "x-world/x-3dmf",
|
||||
".7z": "application/x-7z-compressed",
|
||||
".a": "application/octet-stream",
|
||||
".aab": "application/x-authorware-bin",
|
||||
".aam": "application/x-authorware-map",
|
||||
".aas": "application/x-authorware-seg",
|
||||
".abc": "text/vnd.abc",
|
||||
".acgi": "text/html",
|
||||
".afl": "video/animaflex",
|
||||
".ai": "application/postscript",
|
||||
".aif": "audio/x-aiff",
|
||||
".aifc": "audio/x-aiff",
|
||||
".aiff": "audio/x-aiff",
|
||||
".aim": "application/x-aim",
|
||||
".aip": "text/x-audiosoft-intra",
|
||||
".ani": "application/x-navi-animation",
|
||||
".aos": "application/x-nokia-9000-communicator-add-on-software",
|
||||
".aps": "application/mime",
|
||||
".arc": "application/octet-stream",
|
||||
".arj": "application/octet-stream",
|
||||
".art": "image/x-jg",
|
||||
".asf": "video/x-ms-asf",
|
||||
".asm": "text/x-asm",
|
||||
".asp": "text/asp",
|
||||
".asx": "video/x-ms-asf-plugin",
|
||||
".au": "audio/basic",
|
||||
".avi": "video/x-msvideo",
|
||||
".avs": "video/avs-video",
|
||||
".bcpio": "application/x-bcpio",
|
||||
".bin": "application/octet-stream",
|
||||
".bm": "image/bmp",
|
||||
".bmp": "image/x-ms-bmp",
|
||||
".boo": "application/book",
|
||||
".book": "application/book",
|
||||
".boz": "application/x-bzip2",
|
||||
".bsh": "application/x-bsh",
|
||||
".bz": "application/x-bzip",
|
||||
".bz2": "application/x-bzip2",
|
||||
".c": "text/plain",
|
||||
".c+": "text/plain",
|
||||
".cat": "application/vnd.ms-pki.seccat",
|
||||
".cc": "text/x-c",
|
||||
".ccad": "application/clariscad",
|
||||
".cco": "application/x-cocoa",
|
||||
".cdf": "application/x-netcdf",
|
||||
".cer": "application/x-x509-ca-cert",
|
||||
".cha": "application/x-chat",
|
||||
".chat": "application/x-chat",
|
||||
".class": "application/x-java-class",
|
||||
".com": "text/plain",
|
||||
".conf": "text/plain",
|
||||
".cpio": "application/x-cpio",
|
||||
".cpp": "text/x-c",
|
||||
".cpt": "application/x-cpt",
|
||||
".crl": "application/pkix-crl",
|
||||
".crt": "application/x-x509-user-cert",
|
||||
".csh": "application/x-csh",
|
||||
".css": "text/css",
|
||||
".csv": "text/csv",
|
||||
".cxx": "text/plain",
|
||||
".dcr": "application/x-director",
|
||||
".deepv": "application/x-deepv",
|
||||
".def": "text/plain",
|
||||
".der": "application/x-x509-ca-cert",
|
||||
".dif": "video/x-dv",
|
||||
".dir": "application/x-director",
|
||||
".dl": "video/x-dl",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".dot": "application/msword",
|
||||
".dp": "application/commonground",
|
||||
".drw": "application/drafting",
|
||||
".dump": "application/octet-stream",
|
||||
".dv": "video/x-dv",
|
||||
".dvi": "application/x-dvi",
|
||||
".dwf": "model/vnd.dwf",
|
||||
".dwg": "image/x-dwg",
|
||||
".dxf": "image/x-dwg",
|
||||
".dxr": "application/x-director",
|
||||
".el": "text/x-script.elisp",
|
||||
".elc": "application/x-elc",
|
||||
".env": "application/x-envoy",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
".eps": "application/postscript",
|
||||
".es": "application/x-esrehber",
|
||||
".etx": "text/x-setext",
|
||||
".evy": "application/x-envoy",
|
||||
".exe": "application/octet-stream",
|
||||
".f": "text/x-fortran",
|
||||
".f77": "text/x-fortran",
|
||||
".f90": "text/x-fortran",
|
||||
".fdf": "application/vnd.fdf",
|
||||
".fif": "image/fif",
|
||||
".flac": "audio/flac",
|
||||
".fli": "video/x-fli",
|
||||
".flo": "image/florian",
|
||||
".flx": "text/vnd.fmi.flexstor",
|
||||
".fmf": "video/x-atomic3d-feature",
|
||||
".for": "text/x-fortran",
|
||||
".fpx": "image/vnd.net-fpx",
|
||||
".frl": "application/freeloader",
|
||||
".funk": "audio/make",
|
||||
".g": "text/plain",
|
||||
".g3": "image/g3fax",
|
||||
".gif": "image/gif",
|
||||
".gl": "video/x-gl",
|
||||
".gsd": "audio/x-gsm",
|
||||
".gsm": "audio/x-gsm",
|
||||
".gsp": "application/x-gsp",
|
||||
".gss": "application/x-gss",
|
||||
".gtar": "application/x-gtar",
|
||||
".gz": "application/x-gzip",
|
||||
".gzip": "multipart/x-gzip",
|
||||
".h": "text/plain",
|
||||
".hdf": "application/x-hdf",
|
||||
".help": "application/x-helpfile",
|
||||
".hgl": "application/vnd.hp-hpgl",
|
||||
".hh": "text/x-h",
|
||||
".hlb": "text/x-script",
|
||||
".hlp": "application/x-winhelp",
|
||||
".hpg": "application/vnd.hp-hpgl",
|
||||
".hpgl": "application/vnd.hp-hpgl",
|
||||
".hqx": "application/x-mac-binhex40",
|
||||
".hta": "application/hta",
|
||||
".htc": "text/x-component",
|
||||
".htm": "text/html",
|
||||
".html": "text/html",
|
||||
".htmls": "text/html",
|
||||
".htt": "text/webviewhtml",
|
||||
".htx": "text/html",
|
||||
".ice": "x-conference/x-cooltalk",
|
||||
".ico": "image/vnd.microsoft.icon",
|
||||
".ics": "text/calendar",
|
||||
".idc": "text/plain",
|
||||
".ief": "image/ief",
|
||||
".iefs": "image/ief",
|
||||
".iges": "model/iges",
|
||||
".igs": "model/iges",
|
||||
".ima": "application/x-ima",
|
||||
".imap": "application/x-httpd-imap",
|
||||
".inf": "application/inf",
|
||||
".ins": "application/x-internett-signup",
|
||||
".ip": "application/x-ip2",
|
||||
".isu": "video/x-isvideo",
|
||||
".it": "audio/it",
|
||||
".iv": "application/x-inventor",
|
||||
".ivr": "i-world/i-vrml",
|
||||
".ivy": "application/x-livescreen",
|
||||
".jam": "audio/x-jam",
|
||||
".jav": "text/x-java-source",
|
||||
".java": "text/x-java-source",
|
||||
".jcm": "application/x-java-commerce",
|
||||
".jfif": "image/pjpeg",
|
||||
".jfif-bnl": "image/jpeg",
|
||||
".jpe": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpg",
|
||||
".jps": "image/x-jps",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".jut": "image/jutvision",
|
||||
".kar": "music/x-karaoke",
|
||||
".ksh": "text/plain",
|
||||
".la": "audio/x-nspaudio",
|
||||
".lam": "audio/x-liveaudio",
|
||||
".latex": "application/x-latex",
|
||||
".lha": "application/x-lha",
|
||||
".lhx": "application/octet-stream",
|
||||
".list": "text/plain",
|
||||
".lma": "audio/x-nspaudio",
|
||||
".log": "text/plain",
|
||||
".lsp": "text/x-script.lisp",
|
||||
".lst": "text/plain",
|
||||
".lsx": "text/x-la-asf",
|
||||
".ltx": "application/x-latex",
|
||||
".lzh": "application/x-lzh",
|
||||
".lzx": "application/x-lzx",
|
||||
".m": "text/x-m",
|
||||
".m1v": "video/mpeg",
|
||||
".m2a": "audio/mpeg",
|
||||
".m2v": "video/mpeg",
|
||||
".m3u": "application/vnd.apple.mpegurl",
|
||||
".man": "application/x-troff-man",
|
||||
".map": "application/x-navimap",
|
||||
".mar": "text/plain",
|
||||
".mbd": "application/mbedlet",
|
||||
".mc": "application/x-magic-cap-package-1.0",
|
||||
".mcd": "application/x-mathcad",
|
||||
".mcf": "text/mcf",
|
||||
".mcp": "application/netmc",
|
||||
".me": "application/x-troff-me",
|
||||
".mht": "message/rfc822",
|
||||
".mhtml": "message/rfc822",
|
||||
".mid": "audio/midi",
|
||||
".midi": "audio/midi",
|
||||
".mif": "application/x-mif",
|
||||
".mime": "www/mime",
|
||||
".mjf": "audio/x-vnd.audioexplosion.mjuicemediafile",
|
||||
".mjpg": "video/x-motion-jpeg",
|
||||
".mka": "audio/x-matroska",
|
||||
".mkv": "video/x-matroska",
|
||||
".mm": "application/x-meme",
|
||||
".mme": "application/base64",
|
||||
".mod": "audio/x-mod",
|
||||
".moov": "video/quicktime",
|
||||
".mov": "video/quicktime",
|
||||
".movie": "video/x-sgi-movie",
|
||||
".mp2": "audio/mpeg",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mpa": "video/mpeg",
|
||||
".mpc": "application/x-project",
|
||||
".mpe": "video/mpeg",
|
||||
".mpeg": "video/mpeg",
|
||||
".mpg": "video/mpeg",
|
||||
".mpga": "audio/mpeg",
|
||||
".mpp": "application/vnd.ms-project",
|
||||
".mpt": "application/x-project",
|
||||
".mpv": "application/x-project",
|
||||
".mpx": "application/x-project",
|
||||
".mrc": "application/marc",
|
||||
".ms": "application/x-troff-ms",
|
||||
".mv": "video/x-sgi-movie",
|
||||
".my": "audio/make",
|
||||
".mzz": "application/x-vnd.audioexplosion.mzz",
|
||||
".nap": "image/naplps",
|
||||
".naplps": "image/naplps",
|
||||
".nc": "application/x-netcdf",
|
||||
".ncm": "application/vnd.nokia.configuration-message",
|
||||
".nif": "image/x-niff",
|
||||
".niff": "image/x-niff",
|
||||
".nix": "application/x-mix-transfer",
|
||||
".nsc": "application/x-conference",
|
||||
".nvd": "application/x-navidoc",
|
||||
".o": "application/octet-stream",
|
||||
".oda": "application/oda",
|
||||
".ogg": "video/ogg",
|
||||
".omc": "application/x-omc",
|
||||
".omcd": "application/x-omcdatamaker",
|
||||
".omcr": "application/x-omcregerator",
|
||||
".otf": "font/otf",
|
||||
".p": "text/x-pascal",
|
||||
".p10": "application/x-pkcs10",
|
||||
".p12": "application/x-pkcs12",
|
||||
".p7a": "application/x-pkcs7-signature",
|
||||
".p7c": "application/pkcs7-mime",
|
||||
".p7m": "application/x-pkcs7-mime",
|
||||
".p7r": "application/x-pkcs7-certreqresp",
|
||||
".p7s": "application/pkcs7-signature",
|
||||
".part": "application/pro_eng",
|
||||
".pas": "text/pascal",
|
||||
".pbm": "image/x-portable-bitmap",
|
||||
".pcl": "application/x-pcl",
|
||||
".pct": "image/pict",
|
||||
".pcx": "image/x-pcx",
|
||||
".pdb": "chemical/x-pdb",
|
||||
".pdf": "application/pdf",
|
||||
".pfunk": "audio/make.my.funk",
|
||||
".pgm": "image/x-portable-graymap",
|
||||
".pic": "image/pict",
|
||||
".pict": "image/pict",
|
||||
".pkg": "application/x-newton-compatible-pkg",
|
||||
".pko": "application/vnd.ms-pki.pko",
|
||||
".pl": "text/plain",
|
||||
".plx": "application/x-pixclscript",
|
||||
".pm": "text/x-script.perl-module",
|
||||
".pm4": "application/x-pagemaker",
|
||||
".pm5": "application/x-pagemaker",
|
||||
".png": "image/png",
|
||||
".pnm": "image/x-portable-anymap",
|
||||
".pot": "application/vnd.ms-powerpoint",
|
||||
".pov": "model/x-pov",
|
||||
".ppa": "application/vnd.ms-powerpoint",
|
||||
".ppm": "image/x-portable-pixmap",
|
||||
".pps": "application/vnd.ms-powerpoint",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".ppz": "application/mspowerpoint",
|
||||
".pre": "application/x-freelance",
|
||||
".prt": "application/pro_eng",
|
||||
".ps": "application/postscript",
|
||||
".psd": "application/octet-stream",
|
||||
".pvu": "paleovu/x-pv",
|
||||
".pwz": "application/vnd.ms-powerpoint",
|
||||
".py": "text/x-python",
|
||||
".pyc": "application/x-python-code",
|
||||
".qcp": "audio/vnd.qcelp",
|
||||
".qd3": "x-world/x-3dmf",
|
||||
".qd3d": "x-world/x-3dmf",
|
||||
".qif": "image/x-quicktime",
|
||||
".qt": "video/quicktime",
|
||||
".qtc": "video/x-qtc",
|
||||
".qti": "image/x-quicktime",
|
||||
".qtif": "image/x-quicktime",
|
||||
".ra": "audio/x-pn-realaudio",
|
||||
".ram": "application/x-pn-realaudio",
|
||||
".ras": "image/x-cmu-raster",
|
||||
".rast": "image/cmu-raster",
|
||||
".rar": "application/vnd.rar",
|
||||
".rexx": "text/x-script.rexx",
|
||||
".rf": "image/vnd.rn-realflash",
|
||||
".rgb": "image/x-rgb",
|
||||
".rm": "audio/x-pn-realaudio",
|
||||
".rmi": "audio/mid",
|
||||
".rmm": "audio/x-pn-realaudio",
|
||||
".rmp": "audio/x-pn-realaudio-plugin",
|
||||
".rng": "application/vnd.nokia.ringing-tone",
|
||||
".rnx": "application/vnd.rn-realplayer",
|
||||
".roff": "application/x-troff",
|
||||
".rp": "image/vnd.rn-realpix",
|
||||
".rpm": "audio/x-pn-realaudio-plugin",
|
||||
".rt": "text/vnd.rn-realtext",
|
||||
".rtf": "application/rtf",
|
||||
".rtx": "text/richtext",
|
||||
".rv": "video/vnd.rn-realvideo",
|
||||
".s": "text/x-asm",
|
||||
".s3m": "audio/s3m",
|
||||
".saveme": "application/octet-stream",
|
||||
".sbk": "application/x-tbook",
|
||||
".scm": "video/x-scm",
|
||||
".sdml": "text/plain",
|
||||
".sdp": "application/x-sdp",
|
||||
".sdr": "application/sounder",
|
||||
".sea": "application/x-sea",
|
||||
".set": "application/set",
|
||||
".sgm": "text/x-sgml",
|
||||
".sgml": "text/x-sgml",
|
||||
".sh": "application/x-sh",
|
||||
".shar": "application/x-shar",
|
||||
".shtml": "text/x-server-parsed-html",
|
||||
".sid": "audio/x-psid",
|
||||
".sit": "application/x-stuffit",
|
||||
".skd": "application/x-koan",
|
||||
".skm": "application/x-koan",
|
||||
".skp": "application/x-koan",
|
||||
".skt": "application/x-koan",
|
||||
".sl": "application/x-seelogo",
|
||||
".smi": "application/smil",
|
||||
".smil": "application/smil",
|
||||
".snd": "audio/basic",
|
||||
".sol": "application/solids",
|
||||
".spc": "text/x-speech",
|
||||
".spl": "application/futuresplash",
|
||||
".spr": "application/x-sprite",
|
||||
".sprite": "application/x-sprite",
|
||||
".src": "application/x-wais-source",
|
||||
".ssi": "text/x-server-parsed-html",
|
||||
".ssm": "application/streamingmedia",
|
||||
".sst": "application/vnd.ms-pki.certstore",
|
||||
".step": "application/step",
|
||||
".stl": "application/x-navistyle",
|
||||
".stp": "application/step",
|
||||
".sv4cpio": "application/x-sv4cpio",
|
||||
".sv4crc": "application/x-sv4crc",
|
||||
".svf": "image/x-dwg",
|
||||
".svg": "image/svg+xml",
|
||||
".svr": "x-world/x-svr",
|
||||
".swf": "application/x-shockwave-flash",
|
||||
".t": "application/x-troff",
|
||||
".talk": "text/x-speech",
|
||||
".tar": "application/x-tar",
|
||||
".tbk": "application/x-tbook",
|
||||
".tcl": "application/x-tcl",
|
||||
".tcsh": "text/x-script.tcsh",
|
||||
".tex": "application/x-tex",
|
||||
".texi": "application/x-texinfo",
|
||||
".texinfo": "application/x-texinfo",
|
||||
".text": "text/plain",
|
||||
".tgz": "application/x-compressed",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".tr": "application/x-troff",
|
||||
".ts": "video/mp2t",
|
||||
".tsi": "audio/tsp-audio",
|
||||
".tsp": "audio/tsplayer",
|
||||
".tsv": "text/tab-separated-values",
|
||||
".turbot": "image/florian",
|
||||
".txt": "text/plain",
|
||||
".uil": "text/x-uil",
|
||||
".uni": "text/uri-list",
|
||||
".unis": "text/uri-list",
|
||||
".unv": "application/i-deas",
|
||||
".uri": "text/uri-list",
|
||||
".uris": "text/uri-list",
|
||||
".ustar": "application/x-ustar",
|
||||
".uu": "text/x-uuencode",
|
||||
".uue": "text/x-uuencode",
|
||||
".vcd": "application/x-cdlink",
|
||||
".vcs": "text/x-vcalendar",
|
||||
".vda": "application/vda",
|
||||
".vdo": "video/vdo",
|
||||
".vew": "application/groupwise",
|
||||
".viv": "video/vnd.vivo",
|
||||
".vivo": "video/vnd.vivo",
|
||||
".vmd": "application/vocaltec-media-desc",
|
||||
".vmf": "application/vocaltec-media-file",
|
||||
".voc": "audio/x-voc",
|
||||
".vos": "video/vosaic",
|
||||
".vox": "audio/voxware",
|
||||
".vqe": "audio/x-twinvq-plugin",
|
||||
".vqf": "audio/x-twinvq",
|
||||
".vql": "audio/x-twinvq-plugin",
|
||||
".vrml": "x-world/x-vrml",
|
||||
".vrt": "x-world/x-vrt",
|
||||
".vsd": "application/x-visio",
|
||||
".vst": "application/x-visio",
|
||||
".vsw": "application/x-visio",
|
||||
".w60": "application/wordperfect6.0",
|
||||
".w61": "application/wordperfect6.1",
|
||||
".w6w": "application/msword",
|
||||
".wav": "audio/x-wav",
|
||||
".wb1": "application/x-qpro",
|
||||
".wbmp": "image/vnd.wap.wbmp",
|
||||
".web": "application/vnd.xara",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
".wiz": "application/msword",
|
||||
".wk1": "application/x-123",
|
||||
".wmf": "windows/metafile",
|
||||
".wml": "text/vnd.wap.wml",
|
||||
".wmlc": "application/vnd.wap.wmlc",
|
||||
".wmls": "text/vnd.wap.wmlscript",
|
||||
".wmlsc": "application/vnd.wap.wmlscriptc",
|
||||
".word": "application/msword",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".wp": "application/wordperfect",
|
||||
".wp5": "application/wordperfect6.0",
|
||||
".wp6": "application/wordperfect",
|
||||
".wpd": "application/x-wpwin",
|
||||
".wq1": "application/x-lotus",
|
||||
".wri": "application/x-wri",
|
||||
".wrl": "x-world/x-vrml",
|
||||
".wrz": "x-world/x-vrml",
|
||||
".wsc": "text/scriplet",
|
||||
".wsrc": "application/x-wais-source",
|
||||
".wtk": "application/x-wintalk",
|
||||
".xbm": "image/x-xbitmap",
|
||||
".xdr": "video/x-amt-demorun",
|
||||
".xgz": "xgl/drawing",
|
||||
".xif": "image/vnd.xiff",
|
||||
".xl": "application/excel",
|
||||
".xla": "application/x-msexcel",
|
||||
".xlb": "application/vnd.ms-excel",
|
||||
".xlc": "application/x-excel",
|
||||
".xld": "application/x-excel",
|
||||
".xlk": "application/x-excel",
|
||||
".xll": "application/x-excel",
|
||||
".xlm": "application/x-excel",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlt": "application/x-excel",
|
||||
".xlv": "application/x-excel",
|
||||
".xlw": "application/x-msexcel",
|
||||
".xm": "audio/xm",
|
||||
".xml": "text/xml",
|
||||
".xmz": "xgl/movie",
|
||||
".xpix": "application/x-vnd.ls-xpix",
|
||||
".xpm": "image/x-xpixmap",
|
||||
".x-ng": "image/png",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".xsr": "video/x-amt-showrun",
|
||||
".xwd": "image/x-xwindowdump",
|
||||
".xyz": "chemical/x-pdb",
|
||||
".yaml": "application/x-yaml",
|
||||
".yml": "application/x-yaml",
|
||||
".z": "application/x-compressed",
|
||||
".zip": "application/zip",
|
||||
".zoo": "application/octet-stream",
|
||||
".zsh": "text/x-script.zsh",
|
||||
".mjs": "application/javascript",
|
||||
".webmanifest": "application/manifest+json",
|
||||
".dll": "application/octet-stream",
|
||||
".obj": "application/octet-stream",
|
||||
".so": "application/octet-stream",
|
||||
".m3u8": "application/vnd.apple.mpegurl",
|
||||
".wasm": "application/wasm",
|
||||
".h5": "application/x-hdf5",
|
||||
".pfx": "application/x-pkcs12",
|
||||
".pyo": "application/x-python-code",
|
||||
".xsl": "application/xml",
|
||||
".rdf": "application/xml",
|
||||
".wsdl": "application/xml",
|
||||
".xpdl": "application/xml",
|
||||
".3gp": "audio/3gpp",
|
||||
".3gpp": "audio/3gpp",
|
||||
".3g2": "audio/3gpp2",
|
||||
".3gpp2": "audio/3gpp2",
|
||||
".aac": "audio/aac",
|
||||
".adts": "audio/aac",
|
||||
".loas": "audio/aac",
|
||||
".ass": "audio/aac",
|
||||
".opus": "audio/opus",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".eml": "message/rfc822",
|
||||
".nws": "message/rfc822",
|
||||
".bat": "text/plain",
|
||||
".vcf": "text/x-vcard",
|
||||
".xul": "text/xul",
|
||||
}
|
||||
|
||||
|
||||
def guess(file: t.Union[str, Path]) -> str:
|
||||
return _types.get(Path(file).suffix, _default)
|
||||
|
||||
|
||||
# MIME type -> extension (no leading dot). Used when deriving filenames from
|
||||
# content-type headers (e.g. for URLs without path segments).
|
||||
_MIME_TO_EXT: t.Dict[str, str] = {
|
||||
"text/plain": "txt",
|
||||
"text/html": "html",
|
||||
"text/css": "css",
|
||||
"text/javascript": "js",
|
||||
"application/json": "json",
|
||||
"application/xml": "xml",
|
||||
"application/pdf": "pdf",
|
||||
"application/zip": "zip",
|
||||
"application/x-zip-compressed": "zip",
|
||||
"application/gzip": "gz",
|
||||
"application/octet-stream": "bin",
|
||||
"application/x-tar": "tar",
|
||||
"application/msword": "doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
||||
"application/vnd.ms-excel": "xls",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
||||
"application/vnd.ms-powerpoint": "ppt",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/svg+xml": "svg",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp",
|
||||
"image/tiff": "tiff",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/wav": "wav",
|
||||
"audio/ogg": "ogg",
|
||||
"video/mp4": "mp4",
|
||||
"video/mpeg": "mpeg",
|
||||
"video/quicktime": "mov",
|
||||
"video/x-msvideo": "avi",
|
||||
"video/webm": "webm",
|
||||
}
|
||||
|
||||
|
||||
def get_extension_from_mime_type(mime_type: str) -> str:
|
||||
"""Map MIME type to file extension (no leading dot).
|
||||
|
||||
Used when deriving filenames from content-type headers
|
||||
(e.g. for URLs without path segments).
|
||||
"""
|
||||
clean = mime_type.split(";")[0].lower().strip()
|
||||
if clean in _MIME_TO_EXT:
|
||||
return _MIME_TO_EXT[clean]
|
||||
parts = clean.split("/")
|
||||
if len(parts) == 2:
|
||||
subtype = parts[1].lower()
|
||||
if "+" in subtype:
|
||||
plus_parts = subtype.split("+")
|
||||
suffix = plus_parts[-1]
|
||||
known_prefixes = ("svg", "atom", "rss")
|
||||
if plus_parts[0] in known_prefixes:
|
||||
return plus_parts[0]
|
||||
structured_suffixes = ("json", "xml", "yaml", "zip", "gzip")
|
||||
if suffix in structured_suffixes:
|
||||
return suffix
|
||||
return suffix
|
||||
return subtype or "txt"
|
||||
return "bin"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""OpenAPI helpers."""
|
||||
|
||||
import inspect
|
||||
import typing as t
|
||||
|
||||
from composio.exceptions import InvalidSchemaError
|
||||
|
||||
OPENAPI_TO_PYTHON = {
|
||||
"null": None,
|
||||
"number": float,
|
||||
"integer": int,
|
||||
"boolean": bool,
|
||||
"string": str,
|
||||
}
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _handle_object_type(schema: t.Dict) -> t.Type:
|
||||
# Nested objects are not supported ATM
|
||||
return t.Dict[str, t.Any]
|
||||
|
||||
|
||||
def _handle_array_type(schema: t.Dict) -> t.Any:
|
||||
# This discards the nested objects
|
||||
items_type = schema.get("items", {}).get("type")
|
||||
if items_type is None:
|
||||
return t.List[t.Any]
|
||||
|
||||
SubT = _type_to_parameter(schema=schema.get("items", {}))
|
||||
return t.List[SubT] # type: ignore
|
||||
|
||||
|
||||
def _handle_enum_type(schema: t.Dict) -> t.Any:
|
||||
return t.Literal[tuple(schema["enum"])]
|
||||
|
||||
|
||||
def _type_to_parameter(schema: t.Dict[str, t.Any]) -> t.Any:
|
||||
if "enum" in schema:
|
||||
return _handle_enum_type(schema=schema)
|
||||
|
||||
p_type = schema.get("type")
|
||||
if p_type in OPENAPI_TO_PYTHON:
|
||||
return OPENAPI_TO_PYTHON[p_type]
|
||||
|
||||
if p_type == "object":
|
||||
return _handle_object_type(schema=schema)
|
||||
|
||||
if p_type == "array":
|
||||
return _handle_array_type(schema=schema)
|
||||
|
||||
if p_type is None:
|
||||
# No type specified (e.g. a combiner option that is description-only or
|
||||
# an explicit Any), mirroring the top-level fallback below.
|
||||
return t.Any
|
||||
|
||||
raise InvalidSchemaError(f"Invalid property type {p_type}: {schema!r}")
|
||||
|
||||
|
||||
def _handle_composite_type(schemas: t.List[t.Dict]) -> t.Any:
|
||||
if not schemas:
|
||||
# An empty oneOf/anyOf has no options to union; fall back to Any.
|
||||
return t.Any
|
||||
return t.Union[tuple(map(_type_to_parameter, schemas))]
|
||||
|
||||
|
||||
def _one_of_to_parameter(schema: t.Dict[str, t.Any]) -> t.Any:
|
||||
return _handle_composite_type(schemas=schema["oneOf"])
|
||||
|
||||
|
||||
def _any_of_to_parameter(schema: t.Dict[str, t.Any]) -> t.Any:
|
||||
return _handle_composite_type(schemas=schema["anyOf"])
|
||||
|
||||
|
||||
def _all_of_to_parameter(schema: t.Dict[str, t.Any]) -> t.Type:
|
||||
composite = {}
|
||||
for subschema in schema["allOf"]:
|
||||
composite.update(subschema)
|
||||
return _type_to_parameter(schema=composite)
|
||||
|
||||
|
||||
def function_signature_from_jsonschema(
|
||||
schema: t.Dict[str, t.Any],
|
||||
skip_default: bool = False,
|
||||
) -> t.List[inspect.Parameter]:
|
||||
"""Convert json schema to a list of parameters (`inspect.Parameter`)."""
|
||||
parameters = []
|
||||
required = set(schema.get("required", []))
|
||||
for p_name, p_schema in schema.get("properties", {}).items():
|
||||
if "oneOf" in p_schema:
|
||||
p_type = _one_of_to_parameter(schema=p_schema)
|
||||
elif "anyOf" in p_schema:
|
||||
p_type = _any_of_to_parameter(schema=p_schema)
|
||||
elif "allOf" in p_schema:
|
||||
p_type = _all_of_to_parameter(schema=p_schema)
|
||||
elif "type" in p_schema:
|
||||
p_type = _type_to_parameter(schema=p_schema)
|
||||
else:
|
||||
# Handle cases where no type is specified (e.g., Pydantic's Any type)
|
||||
# This typically happens when using typing.Any in Pydantic models,
|
||||
# which intentionally omits the 'type' field in JSON schema
|
||||
p_type = t.Any
|
||||
|
||||
p_val = p_schema.get("default", None)
|
||||
if p_name in required or p_schema.get("required", False) or skip_default:
|
||||
p_val = inspect.Parameter.empty
|
||||
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
name=p_name,
|
||||
annotation=p_type,
|
||||
default=p_val,
|
||||
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
)
|
||||
)
|
||||
|
||||
return parameters
|
||||
@@ -0,0 +1,44 @@
|
||||
import typing as t
|
||||
|
||||
import pydantic
|
||||
from composio_client import omit
|
||||
|
||||
|
||||
def none_to_omit(value: t.Optional[t.Any]) -> t.Any:
|
||||
"""Convert None to omit for composio_client API calls.
|
||||
|
||||
This utility function helps convert Python's None values to composio_client's
|
||||
omit sentinel value, which tells the API to exclude the parameter entirely.
|
||||
|
||||
Args:
|
||||
value: Any value that might be None
|
||||
|
||||
Returns:
|
||||
The original value if not None, otherwise omit
|
||||
|
||||
Example:
|
||||
>>> none_to_omit("hello")
|
||||
"hello"
|
||||
>>> none_to_omit(None)
|
||||
<omit>
|
||||
>>> none_to_omit(42)
|
||||
42
|
||||
"""
|
||||
return omit if value is None else value
|
||||
|
||||
|
||||
def parse_pydantic_error(e: pydantic.ValidationError) -> str:
|
||||
"""Parse pydantic validation error."""
|
||||
message = "Invalid request data provided"
|
||||
missing = []
|
||||
others = [""]
|
||||
for error in e.errors():
|
||||
param = ".".join(map(str, error["loc"]))
|
||||
if error["type"] == "missing":
|
||||
missing.append(param)
|
||||
continue
|
||||
others.append(error["msg"] + f" on parameter `{param}`")
|
||||
if len(missing) > 0:
|
||||
message += f"\n- Following fields are missing: {set(missing)}"
|
||||
message += "\n- ".join(others)
|
||||
return message
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
JSON Schema to Pydantic type conversion using json-schema-to-pydantic library.
|
||||
|
||||
This module provides a wrapper around json-schema-to-pydantic that maintains
|
||||
backwards compatibility with the existing json_schema_to_pydantic_type() API.
|
||||
|
||||
The library handles most JSON Schema features correctly, but doesn't support
|
||||
boolean schemas (true/false values that are valid in JSON Schema draft-06+).
|
||||
We handle those by pre-filtering them before passing to the library.
|
||||
"""
|
||||
|
||||
import typing as t
|
||||
from functools import reduce
|
||||
|
||||
from json_schema_to_pydantic import (
|
||||
CombinerError,
|
||||
SchemaError,
|
||||
create_model as create_model_from_schema,
|
||||
)
|
||||
|
||||
from composio.utils.logging import get as get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Type mapping for simple cases where we don't need full model creation
|
||||
PYDANTIC_TYPE_TO_PYTHON_TYPE = {
|
||||
"string": str,
|
||||
"integer": int,
|
||||
"number": float,
|
||||
"boolean": bool,
|
||||
"array": t.List,
|
||||
"object": t.Dict,
|
||||
"null": t.Optional[t.Any],
|
||||
}
|
||||
|
||||
CONTAINER_TYPE = ("array", "object")
|
||||
|
||||
# Should be deprecated,
|
||||
# required values will always be provided by users
|
||||
# Non-required values are nullable(None) if default value not provided.
|
||||
FALLBACK_VALUES = {
|
||||
"string": "",
|
||||
"number": 0.0,
|
||||
"integer": 0,
|
||||
"boolean": False,
|
||||
"object": {},
|
||||
"array": [],
|
||||
"null": None,
|
||||
}
|
||||
|
||||
|
||||
def _filter_boolean_schemas(
|
||||
schema: t.Union[t.Dict[str, t.Any], bool, t.List],
|
||||
) -> t.Union[t.Dict[str, t.Any], t.Any, None]:
|
||||
"""
|
||||
Pre-filter boolean schemas from anyOf/allOf/oneOf arrays.
|
||||
|
||||
JSON Schema draft-06+ allows `true` and `false` as valid schemas:
|
||||
- `true` means "accept any value" (equivalent to {})
|
||||
- `false` means "reject all values" (equivalent to {"not": {}})
|
||||
|
||||
The json-schema-to-pydantic library doesn't handle these, so we:
|
||||
- Replace `true` with {} (empty schema, accepts anything)
|
||||
- Filter out `false` (rejects everything, so no point including it)
|
||||
|
||||
Returns None if the schema is a standalone `false` boolean.
|
||||
"""
|
||||
if isinstance(schema, bool):
|
||||
if schema:
|
||||
# true -> empty schema (accepts any value)
|
||||
return {}
|
||||
else:
|
||||
# false -> reject all, return None to filter out
|
||||
return None
|
||||
|
||||
if isinstance(schema, list):
|
||||
# Filter list items (e.g., for anyOf arrays)
|
||||
filtered = []
|
||||
for item in schema:
|
||||
result = _filter_boolean_schemas(item)
|
||||
if result is not None:
|
||||
filtered.append(result)
|
||||
return filtered if filtered else None
|
||||
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# Make a copy to avoid mutating the original
|
||||
result = {}
|
||||
for key, value in schema.items():
|
||||
if key in ("anyOf", "allOf", "oneOf"):
|
||||
# Filter boolean schemas from combiner arrays
|
||||
filtered_value = _filter_boolean_schemas(value)
|
||||
if filtered_value is None or (
|
||||
isinstance(filtered_value, list) and len(filtered_value) == 0
|
||||
):
|
||||
# All schemas were false, skip this combiner
|
||||
continue
|
||||
result[key] = filtered_value
|
||||
elif key == "items" and isinstance(value, (dict, bool)):
|
||||
# Handle array items schema
|
||||
filtered_items = _filter_boolean_schemas(value)
|
||||
if filtered_items is not None:
|
||||
result[key] = filtered_items
|
||||
elif key == "properties" and isinstance(value, dict):
|
||||
# Recursively filter property schemas
|
||||
filtered_props = {}
|
||||
for prop_name, prop_schema in value.items():
|
||||
filtered_prop = _filter_boolean_schemas(prop_schema)
|
||||
if filtered_prop is not None:
|
||||
filtered_props[prop_name] = filtered_prop
|
||||
result[key] = filtered_props
|
||||
elif key in ("$defs", "definitions") and isinstance(value, dict):
|
||||
# Recursively filter definitions
|
||||
filtered_defs = {}
|
||||
for def_name, def_schema in value.items():
|
||||
filtered_def = _filter_boolean_schemas(def_schema)
|
||||
if filtered_def is not None:
|
||||
filtered_defs[def_name] = filtered_def
|
||||
result[key] = filtered_defs
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def json_schema_to_pydantic_type(
|
||||
json_schema: t.Union[t.Dict[str, t.Any], bool],
|
||||
) -> t.Union[t.Type, t.Optional[t.Any]]:
|
||||
"""
|
||||
Converts a JSON schema type to a Pydantic type.
|
||||
|
||||
Uses json-schema-to-pydantic for complex schemas (anyOf, allOf, oneOf),
|
||||
falls back to simple type mapping for primitive types.
|
||||
|
||||
:param json_schema: The JSON schema to convert (can be dict or boolean).
|
||||
:return: A Pydantic type.
|
||||
"""
|
||||
# Handle boolean schemas (JSON Schema draft-06+)
|
||||
if isinstance(json_schema, bool):
|
||||
if json_schema:
|
||||
return t.Any # true schema accepts any value
|
||||
else:
|
||||
return None # false schema - will be filtered out in union processing
|
||||
|
||||
# Pre-filter boolean schemas from combiners
|
||||
filtered_schema = _filter_boolean_schemas(json_schema)
|
||||
if filtered_schema is None:
|
||||
return str # Fallback if all schemas were false
|
||||
|
||||
# Handle simple primitive types without complex combiners
|
||||
if _is_simple_primitive(filtered_schema):
|
||||
return _convert_simple_type(filtered_schema)
|
||||
|
||||
# Use library for complex schemas (anyOf, allOf, oneOf, nested objects)
|
||||
return _convert_with_library(filtered_schema)
|
||||
|
||||
|
||||
def _is_simple_primitive(schema: t.Dict[str, t.Any]) -> bool:
|
||||
"""Check if schema is a simple primitive without combiners."""
|
||||
has_combiners = any(k in schema for k in ("anyOf", "allOf", "oneOf"))
|
||||
has_properties = "properties" in schema
|
||||
schema_type = schema.get("type")
|
||||
|
||||
return (
|
||||
not has_combiners
|
||||
and not has_properties
|
||||
and schema_type in PYDANTIC_TYPE_TO_PYTHON_TYPE
|
||||
and schema_type not in CONTAINER_TYPE
|
||||
)
|
||||
|
||||
|
||||
def _convert_simple_type(schema: t.Dict[str, t.Any]) -> t.Type[t.Any]:
|
||||
"""Convert simple primitive types directly."""
|
||||
type_ = schema.get("type", "string")
|
||||
return t.cast(t.Type[t.Any], PYDANTIC_TYPE_TO_PYTHON_TYPE.get(type_, str))
|
||||
|
||||
|
||||
def _convert_with_library(
|
||||
schema: t.Dict[str, t.Any],
|
||||
) -> t.Union[t.Type, t.Any]:
|
||||
"""Use json-schema-to-pydantic for complex schema conversion."""
|
||||
try:
|
||||
# Handle top-level combiner without type (e.g., {"anyOf": [...]})
|
||||
if (
|
||||
any(k in schema for k in ("anyOf", "allOf", "oneOf"))
|
||||
and "type" not in schema
|
||||
):
|
||||
return _handle_toplevel_combiner(schema)
|
||||
|
||||
# For object schemas, create model directly
|
||||
if schema.get("type") == "object":
|
||||
if "title" not in schema:
|
||||
schema = {**schema, "title": "GeneratedModel"}
|
||||
return create_model_from_schema(
|
||||
schema,
|
||||
allow_undefined_array_items=True,
|
||||
allow_undefined_type=True,
|
||||
)
|
||||
|
||||
# For array schemas
|
||||
if schema.get("type") == "array":
|
||||
items = schema.get("items")
|
||||
if items:
|
||||
item_type = json_schema_to_pydantic_type(items)
|
||||
return t.List[t.cast(t.Type, item_type)] # type: ignore
|
||||
return t.List
|
||||
|
||||
# Fallback to simple type
|
||||
return _convert_simple_type(schema)
|
||||
|
||||
except (SchemaError, CombinerError) as e:
|
||||
logger.debug(f"Library schema conversion failed: {e}, falling back to string")
|
||||
return str
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Unexpected error in schema conversion: {e}, falling back to string"
|
||||
)
|
||||
return str
|
||||
|
||||
|
||||
def _handle_toplevel_combiner(
|
||||
schema: t.Dict[str, t.Any],
|
||||
) -> t.Union[t.Type, t.Any]:
|
||||
"""
|
||||
Handle top-level combiner schemas (anyOf, allOf, oneOf without "type").
|
||||
|
||||
The library can handle these directly - it returns the appropriate type.
|
||||
"""
|
||||
try:
|
||||
# Try direct conversion - library handles anyOf/oneOf/allOf at top level
|
||||
result = create_model_from_schema(
|
||||
schema,
|
||||
allow_undefined_array_items=True,
|
||||
allow_undefined_type=True,
|
||||
)
|
||||
if result is type(None):
|
||||
return t.Optional[t.Any]
|
||||
# If result is a type (like a Union or Optional), return it directly
|
||||
# If result is a model class, return it
|
||||
return result
|
||||
except (SchemaError, CombinerError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: manually build union type for anyOf/oneOf
|
||||
if "anyOf" in schema or "oneOf" in schema:
|
||||
options = schema.get("anyOf", schema.get("oneOf", []))
|
||||
return _build_union_from_options(options)
|
||||
|
||||
# Fallback: use first option for allOf
|
||||
if "allOf" in schema and schema["allOf"]:
|
||||
return json_schema_to_pydantic_type(schema["allOf"][0])
|
||||
|
||||
return t.Any
|
||||
|
||||
|
||||
def _build_union_from_options(options: t.List[t.Dict[str, t.Any]]) -> t.Type:
|
||||
"""Build a Union type from a list of schema options."""
|
||||
pydantic_types = []
|
||||
null_type = PYDANTIC_TYPE_TO_PYTHON_TYPE.get("null")
|
||||
has_null = False
|
||||
|
||||
for option in options:
|
||||
ptype = json_schema_to_pydantic_type(option)
|
||||
if ptype is None:
|
||||
continue
|
||||
if ptype == null_type or ptype is type(None):
|
||||
has_null = True
|
||||
continue
|
||||
pydantic_types.append(ptype)
|
||||
|
||||
if len(pydantic_types) == 0:
|
||||
return t.Optional[t.Any] if has_null else str # type: ignore
|
||||
|
||||
if len(pydantic_types) == 1:
|
||||
base_type = pydantic_types[0]
|
||||
if has_null:
|
||||
return t.Optional[t.cast(t.Type, base_type)] # type: ignore
|
||||
return base_type
|
||||
|
||||
# Build union
|
||||
cast_types = [t.cast(t.Type, ptype) for ptype in pydantic_types]
|
||||
union_type = reduce(lambda a, b: t.Union[a, b], cast_types) # type: ignore
|
||||
if has_null:
|
||||
return t.Optional[union_type] # type: ignore
|
||||
return union_type
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Block accidental upload of local files from well-known secret/credential locations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
# Path components that indicate a sensitive directory anywhere in a resolved local path.
|
||||
BUILTIN_FILE_UPLOAD_PATH_DENY_SEGMENTS: t.Tuple[str, ...] = (
|
||||
".ssh",
|
||||
".aws",
|
||||
".azure",
|
||||
".gnupg",
|
||||
".kube",
|
||||
".docker",
|
||||
".claude", # may contain API keys and project context read by assistants
|
||||
".password-store",
|
||||
"keychains",
|
||||
)
|
||||
|
||||
_SECRET_LIKE_BASENAME = re.compile(r"^(\.env(\.|$)|\.netrc$|\.pgpass$)", re.IGNORECASE)
|
||||
_DEFAULT_PRIVATE_KEY_BASENAME = re.compile(
|
||||
r"^id_(rsa|ed25519|ecdsa|dsa|ecdsa_sk)(\.old)?$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _normalize_path_segments(file_path: t.Union[str, Path]) -> t.List[str]:
|
||||
p = Path(file_path).expanduser()
|
||||
try:
|
||||
resolved = p.resolve()
|
||||
except OSError:
|
||||
resolved = p
|
||||
parts = [part for part in resolved.as_posix().split("/") if part]
|
||||
if parts and parts[0].endswith(":"):
|
||||
# Windows path: "C:/Users/..." -> drop drive letter segment
|
||||
parts = parts[1:]
|
||||
return parts
|
||||
|
||||
|
||||
def _get_block_reason(
|
||||
file_path: t.Union[str, Path],
|
||||
additional_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
) -> t.Optional[str]:
|
||||
extra = [s.strip() for s in (additional_deny_segments or []) if s and s.strip()]
|
||||
deny: t.Set[str] = {s.lower() for s in BUILTIN_FILE_UPLOAD_PATH_DENY_SEGMENTS}
|
||||
deny.update(s.lower() for s in extra)
|
||||
|
||||
segments = _normalize_path_segments(file_path)
|
||||
# Case-insensitive segment match: lower each path component once per check.
|
||||
for seg, key in zip(segments, (s.lower() for s in segments), strict=True):
|
||||
if key in deny:
|
||||
return f'path segment "{seg}" is in the sensitive file upload denylist'
|
||||
|
||||
if not segments:
|
||||
return None
|
||||
basename = segments[-1]
|
||||
if _SECRET_LIKE_BASENAME.search(basename) or _DEFAULT_PRIVATE_KEY_BASENAME.match(
|
||||
basename
|
||||
):
|
||||
return (
|
||||
f'file name "{basename}" looks like a credential, env, or private key file'
|
||||
)
|
||||
if basename.lower() == "credentials":
|
||||
return 'file name "credentials" is often used for cloud/API credential stores'
|
||||
return None
|
||||
|
||||
|
||||
def is_blocked_sensitive_file_upload_path(
|
||||
file_path: t.Union[str, Path],
|
||||
additional_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
) -> bool:
|
||||
return _get_block_reason(file_path, additional_deny_segments) is not None
|
||||
|
||||
|
||||
def assert_safe_local_file_upload_path(
|
||||
file_path: t.Union[str, Path],
|
||||
*,
|
||||
enabled: bool = True,
|
||||
additional_deny_segments: t.Optional[t.Sequence[str]] = None,
|
||||
) -> None:
|
||||
"""Raise SensitiveFilePathBlockedError if *file_path* matches the denylist."""
|
||||
if not enabled:
|
||||
return
|
||||
reason = _get_block_reason(file_path, additional_deny_segments)
|
||||
if reason:
|
||||
from composio.exceptions import SensitiveFilePathBlockedError
|
||||
|
||||
raise SensitiveFilePathBlockedError(
|
||||
f"Refusing to upload: {reason}. "
|
||||
"Set sensitive_file_upload_protection=False on Composio if you must "
|
||||
"(not recommended), or use a copy outside sensitive locations."
|
||||
)
|
||||
@@ -0,0 +1,672 @@
|
||||
"""
|
||||
Shared utils.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import keyword
|
||||
import typing as t
|
||||
import uuid
|
||||
from functools import reduce
|
||||
from inspect import Parameter
|
||||
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from composio.exceptions import InvalidParams, InvalidSchemaError
|
||||
from composio.utils.json_schema import dereference_json_schema
|
||||
from composio.utils.logging import get as get_logger
|
||||
from composio.utils.schema_converter import (
|
||||
CONTAINER_TYPE,
|
||||
FALLBACK_VALUES,
|
||||
PYDANTIC_TYPE_TO_PYTHON_TYPE,
|
||||
json_schema_to_pydantic_type,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Re-export for backward compatibility
|
||||
__all__ = [
|
||||
"json_schema_to_pydantic_type",
|
||||
"PYDANTIC_TYPE_TO_PYTHON_TYPE",
|
||||
"CONTAINER_TYPE",
|
||||
"FALLBACK_VALUES",
|
||||
"json_schema_to_pydantic_field",
|
||||
"json_schema_to_fields_dict",
|
||||
"json_schema_to_model",
|
||||
"pydantic_model_from_param_schema",
|
||||
"get_signature_format_from_schema_params",
|
||||
"get_pydantic_signature_format_from_schema_params",
|
||||
"generate_request_id",
|
||||
"ToolSchemaAliases",
|
||||
"alias_tool_input_schema",
|
||||
"restore_tool_arguments",
|
||||
"substitute_reserved_python_keywords",
|
||||
"reinstate_reserved_python_keywords",
|
||||
"normalize_tool_arguments",
|
||||
]
|
||||
|
||||
reserved_names = ["validate"]
|
||||
|
||||
_OBJ_MARKER = "-_object_-"
|
||||
_ARR_MARKER = "-_array_-"
|
||||
_MAX_PROVIDER_ALIAS_LENGTH = 64
|
||||
|
||||
|
||||
def normalize_tool_arguments(arguments: t.Any) -> t.Dict[str, t.Any]:
|
||||
"""Coerce model-supplied tool arguments into a dict.
|
||||
|
||||
Models (and some MCP transports) occasionally emit tool-call arguments as a
|
||||
JSON string instead of a dict, which breaks execution with errors such as
|
||||
``tool_use.input: Input should be a valid dictionary``. This is the single
|
||||
coercion every provider routes through so behaviour is identical everywhere.
|
||||
|
||||
See https://github.com/ComposioHQ/composio/issues/2406.
|
||||
|
||||
- ``None`` becomes ``{}`` (some models send no arguments for no-arg tools).
|
||||
- A dict is returned unchanged.
|
||||
- A string is JSON-parsed; an empty / whitespace-only string becomes ``{}``.
|
||||
- Anything that does not resolve to a dict (lists, primitives, unparseable
|
||||
strings, JSON that parses to a non-object) raises :class:`InvalidParams`.
|
||||
|
||||
:param arguments: Raw arguments as received from the model / framework.
|
||||
:return: The normalized arguments as a dict.
|
||||
:raises InvalidParams: If the arguments cannot be resolved to a dict.
|
||||
"""
|
||||
if arguments is None:
|
||||
return {}
|
||||
|
||||
if isinstance(arguments, str):
|
||||
stripped = arguments.strip()
|
||||
if not stripped:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except json.JSONDecodeError as e:
|
||||
raise InvalidParams(
|
||||
f"Tool arguments were provided as a string that is not valid JSON: {e}"
|
||||
) from e
|
||||
return _as_dict(parsed)
|
||||
|
||||
return _as_dict(arguments)
|
||||
|
||||
|
||||
def _as_dict(value: t.Any) -> t.Dict[str, t.Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
raise InvalidParams(
|
||||
f"Tool arguments must resolve to an object, received {type(value).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def _make_safe_name(name: str) -> str:
|
||||
"""Append ``_rs`` to a Python keyword so it can be used as a parameter name."""
|
||||
return f"{name}_rs"
|
||||
|
||||
|
||||
def _make_python_identifier(name: str) -> str:
|
||||
if keyword.iskeyword(name):
|
||||
return _make_safe_name(name)
|
||||
|
||||
safe = "".join(
|
||||
char if (char.isascii() and (char.isalnum() or char == "_")) else "_"
|
||||
for char in name
|
||||
)
|
||||
if not safe:
|
||||
safe = "param"
|
||||
if safe[0].isdigit() or safe[0] == "_":
|
||||
safe = f"param_{safe.lstrip('_') or 'value'}"
|
||||
if keyword.iskeyword(safe):
|
||||
safe = _make_safe_name(safe)
|
||||
if len(safe) > _MAX_PROVIDER_ALIAS_LENGTH:
|
||||
hash_suffix = hashlib.sha256(name.encode()).hexdigest()[:8]
|
||||
safe = (
|
||||
f"{safe[: _MAX_PROVIDER_ALIAS_LENGTH - len(hash_suffix) - 1]}_{hash_suffix}"
|
||||
)
|
||||
return safe
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ToolSchemaAliases:
|
||||
"""Provider-visible schema plus mapping back to backend argument names."""
|
||||
|
||||
schema: t.Dict[str, t.Any]
|
||||
aliases: t.Dict[str, t.Any]
|
||||
|
||||
def restore_arguments(self, arguments: dict) -> dict:
|
||||
return restore_tool_arguments(arguments, self.aliases)
|
||||
|
||||
|
||||
def alias_tool_input_schema(schema: t.Dict) -> ToolSchemaAliases:
|
||||
"""Alias tool input schema keys so they are valid Python parameter names.
|
||||
|
||||
Returns a :class:`ToolSchemaAliases` object containing a deep-copied schema
|
||||
for provider/framework exposure and a reverse alias map for restoring model
|
||||
arguments before calling the backend executor.
|
||||
|
||||
Python keywords keep the historical ``_rs`` suffix (``from`` becomes
|
||||
``from_rs``). Other names that cannot be used as Python identifiers are
|
||||
converted to Pydantic-safe identifiers and long aliases are capped at 64
|
||||
characters so the same provider-facing schema is accepted by
|
||||
Anthropic-style tool schema validators. Internal JSON Schema references are
|
||||
inlined before aliasing so referenced object properties are exposed through
|
||||
the same safe names. If two properties would expose the same alias, an
|
||||
:class:`InvalidSchemaError` is raised instead of guessing.
|
||||
"""
|
||||
aliased_schema = t.cast(
|
||||
t.Dict[str, t.Any],
|
||||
dereference_json_schema(
|
||||
copy.deepcopy(schema),
|
||||
on_unresolved="sentinel",
|
||||
),
|
||||
)
|
||||
schema_params, aliases = _alias_schema_properties(aliased_schema)
|
||||
return ToolSchemaAliases(schema=schema_params, aliases=aliases)
|
||||
|
||||
|
||||
def _alias_schema_properties(schema: t.Dict[str, t.Any]) -> t.Tuple[dict, dict]:
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
return schema, {}
|
||||
|
||||
aliases: t.Dict[str, t.Any] = {}
|
||||
aliased_properties: t.Dict[str, t.Any] = {}
|
||||
|
||||
for original_name, property_schema in properties.items():
|
||||
safe_name = _make_python_identifier(original_name)
|
||||
if safe_name in aliased_properties:
|
||||
raise InvalidSchemaError(
|
||||
"Tool input schema property names produce a duplicate Python "
|
||||
f"parameter alias {safe_name!r}"
|
||||
)
|
||||
|
||||
nested_object_aliases: t.Dict[str, t.Any] = {}
|
||||
nested_array_aliases: t.Dict[str, t.Any] = {}
|
||||
if isinstance(property_schema, dict):
|
||||
property_schema, nested_object_aliases = _alias_nested_object_schema(
|
||||
property_schema
|
||||
)
|
||||
property_schema, nested_array_aliases = _alias_nested_array_schema(
|
||||
property_schema
|
||||
)
|
||||
|
||||
aliased_properties[safe_name] = property_schema
|
||||
if safe_name != original_name or nested_object_aliases or nested_array_aliases:
|
||||
aliases[safe_name] = original_name
|
||||
if nested_object_aliases:
|
||||
aliases[f"{safe_name}{_OBJ_MARKER}"] = nested_object_aliases
|
||||
if nested_array_aliases:
|
||||
aliases[f"{safe_name}{_ARR_MARKER}"] = nested_array_aliases
|
||||
|
||||
schema["properties"] = aliased_properties
|
||||
if aliases and "required" in schema:
|
||||
reverse = {
|
||||
original: safe
|
||||
for safe, original in aliases.items()
|
||||
if not safe.endswith(_OBJ_MARKER) and not safe.endswith(_ARR_MARKER)
|
||||
}
|
||||
schema["required"] = [reverse.get(r, r) for r in schema["required"]]
|
||||
|
||||
return schema, aliases
|
||||
|
||||
|
||||
def _alias_nested_object_schema(
|
||||
property_schema: t.Dict[str, t.Any],
|
||||
) -> t.Tuple[dict, dict]:
|
||||
if not isinstance(property_schema.get("properties"), dict):
|
||||
return property_schema, {}
|
||||
return _alias_schema_properties(property_schema)
|
||||
|
||||
|
||||
def _alias_nested_array_schema(
|
||||
property_schema: t.Dict[str, t.Any],
|
||||
) -> t.Tuple[dict, dict]:
|
||||
items_schema = property_schema.get("items")
|
||||
if not isinstance(items_schema, dict):
|
||||
return property_schema, {}
|
||||
aliased_items, aliases = _alias_schema_properties(items_schema)
|
||||
if aliases:
|
||||
property_schema["items"] = aliased_items
|
||||
return property_schema, aliases
|
||||
|
||||
|
||||
def restore_tool_arguments(request: dict, aliases: dict) -> dict:
|
||||
"""Restore provider-visible argument aliases back to backend schema names.
|
||||
|
||||
Modifies *request* in-place and returns it.
|
||||
"""
|
||||
alias_keys = [
|
||||
key
|
||||
for key in aliases
|
||||
if not key.endswith(_OBJ_MARKER) and not key.endswith(_ARR_MARKER)
|
||||
]
|
||||
for clean_key in sorted(alias_keys, reverse=True):
|
||||
if clean_key not in request:
|
||||
continue
|
||||
|
||||
original_value = request.pop(clean_key)
|
||||
object_aliases = aliases.get(f"{clean_key}{_OBJ_MARKER}", {})
|
||||
array_aliases = aliases.get(f"{clean_key}{_ARR_MARKER}", {})
|
||||
if object_aliases and isinstance(original_value, dict):
|
||||
original_value = restore_tool_arguments(
|
||||
request=original_value,
|
||||
aliases=object_aliases,
|
||||
)
|
||||
if array_aliases and isinstance(original_value, list):
|
||||
original_value = [
|
||||
restore_tool_arguments(item, array_aliases)
|
||||
if isinstance(item, dict)
|
||||
else item
|
||||
for item in original_value
|
||||
]
|
||||
request[aliases.get(clean_key, clean_key)] = original_value
|
||||
return request
|
||||
|
||||
|
||||
def substitute_reserved_python_keywords(
|
||||
schema: t.Dict,
|
||||
) -> t.Tuple[dict, dict]:
|
||||
"""Replace unsafe JSON schema property names with Python parameter aliases.
|
||||
|
||||
Backward-compatible wrapper around :func:`alias_tool_input_schema`.
|
||||
"""
|
||||
aliased = alias_tool_input_schema(schema=schema)
|
||||
return aliased.schema, aliased.aliases
|
||||
|
||||
|
||||
def reinstate_reserved_python_keywords(
|
||||
request: dict,
|
||||
keywords: dict,
|
||||
) -> dict:
|
||||
"""Reverse the substitution performed by :func:`substitute_reserved_python_keywords`.
|
||||
|
||||
Modifies *request* **in-place** and returns it.
|
||||
"""
|
||||
return restore_tool_arguments(request=request, aliases=keywords)
|
||||
|
||||
|
||||
def _coerce_default_value(
|
||||
default: t.Any,
|
||||
json_schema: t.Dict[str, t.Any],
|
||||
) -> t.Any:
|
||||
"""
|
||||
Coerce a default value to match the expected type from JSON schema.
|
||||
|
||||
Handles common mismatches where string defaults should be boolean/int/float.
|
||||
This fixes issues where API returns stringified defaults like "true" instead of true.
|
||||
|
||||
Coercion precedence: boolean > integer > float. This means values like "1" and "0"
|
||||
become booleans when both bool and int are expected types.
|
||||
|
||||
:param default: The default value from the JSON schema.
|
||||
:param json_schema: The JSON schema property definition.
|
||||
:return: The coerced default value, or original if no coercion possible.
|
||||
"""
|
||||
if default is None or not isinstance(default, str):
|
||||
return default
|
||||
|
||||
# Collect expected types from schema
|
||||
expected_types: t.Set[t.Any] = set()
|
||||
|
||||
if "type" in json_schema:
|
||||
py_type = PYDANTIC_TYPE_TO_PYTHON_TYPE.get(json_schema["type"])
|
||||
if py_type is not None:
|
||||
expected_types.add(py_type)
|
||||
|
||||
for combiner in ("anyOf", "oneOf", "allOf"):
|
||||
for option in json_schema.get(combiner, []):
|
||||
if isinstance(option, dict):
|
||||
option_type = option.get("type")
|
||||
if isinstance(option_type, str):
|
||||
py_type = PYDANTIC_TYPE_TO_PYTHON_TYPE.get(option_type)
|
||||
if py_type is not None:
|
||||
expected_types.add(py_type)
|
||||
|
||||
# If string is expected, no coercion needed
|
||||
if str in expected_types:
|
||||
return default
|
||||
|
||||
# Boolean coercion (takes precedence over int for "1"/"0")
|
||||
if bool in expected_types:
|
||||
lower_default = default.lower()
|
||||
if lower_default in ("true", "yes", "1"):
|
||||
return True
|
||||
if lower_default in ("false", "no", "0"):
|
||||
return False
|
||||
|
||||
# Integer coercion
|
||||
if int in expected_types:
|
||||
try:
|
||||
return int(default)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Float coercion
|
||||
if float in expected_types:
|
||||
try:
|
||||
return float(default)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def json_schema_to_pydantic_field(
|
||||
name: str,
|
||||
json_schema: t.Dict[str, t.Any],
|
||||
required: t.List[str],
|
||||
skip_default: bool = False,
|
||||
) -> t.Tuple[str, t.Type, FieldInfo]:
|
||||
"""
|
||||
Converts a JSON schema property to a Pydantic field definition.
|
||||
|
||||
:param name: The field name.
|
||||
:param json_schema: The JSON schema property.
|
||||
:param required: List of required properties.
|
||||
:return: A Pydantic field definition.
|
||||
"""
|
||||
description = json_schema.get("description")
|
||||
if "oneOf" in json_schema:
|
||||
description = " | ".join(
|
||||
[option.get("description", "") for option in json_schema["oneOf"]]
|
||||
)
|
||||
description = f"Any of the following options(separated by |): {description}"
|
||||
|
||||
examples = json_schema.get("examples", [])
|
||||
default = json_schema.get("default")
|
||||
|
||||
# Coerce default value to match expected type from schema
|
||||
if default is not None:
|
||||
default = _coerce_default_value(default, json_schema)
|
||||
|
||||
# Check if the field name is a reserved Pydantic name
|
||||
original_name = name
|
||||
if name in reserved_names:
|
||||
name = f"{name}_"
|
||||
alias = original_name
|
||||
else:
|
||||
alias = None
|
||||
|
||||
field = {
|
||||
"description": description,
|
||||
"examples": examples,
|
||||
"alias": alias,
|
||||
}
|
||||
if not skip_default:
|
||||
field["default"] = ... if original_name in required else default
|
||||
|
||||
return (
|
||||
name,
|
||||
t.cast(
|
||||
t.Type,
|
||||
json_schema_to_pydantic_type(
|
||||
json_schema=json_schema,
|
||||
),
|
||||
),
|
||||
Field(**field), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def json_schema_to_fields_dict(json_schema: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
|
||||
"""
|
||||
Converts a JSON schema to a dictionary of param name, and a tuple of type & Field.
|
||||
|
||||
:param json_schema: The JSON schema to convert.
|
||||
:return: dict<str, tuple<<class 'type'>, Field>>
|
||||
|
||||
Example Output:
|
||||
```python
|
||||
{
|
||||
'owner': (<class 'str'>, FieldInfo(default=Ellipsis, description='The account owner of the repository.', extra={'examples': ([],)})),
|
||||
'repo': (<class 'str'>, FieldInfo(default=Ellipsis, description='The name of the repository without the `.git` extension.', extra={'examples': ([],)}))}
|
||||
}
|
||||
```
|
||||
|
||||
"""
|
||||
field_definitions = {}
|
||||
for name, prop in json_schema.get("properties", {}).items():
|
||||
updated_name, pydantic_type, pydantic_field = json_schema_to_pydantic_field(
|
||||
name, prop, json_schema.get("required", [])
|
||||
)
|
||||
field_definitions[updated_name] = (pydantic_type, pydantic_field)
|
||||
return field_definitions # type: ignore
|
||||
|
||||
|
||||
def json_schema_to_model(
|
||||
json_schema: t.Dict[str, t.Any],
|
||||
skip_default: bool = False,
|
||||
) -> t.Type[BaseModel]:
|
||||
"""
|
||||
Converts a JSON schema to a Pydantic BaseModel class.
|
||||
|
||||
:param json_schema: The JSON schema to convert.
|
||||
:param skip_default: Skip the default values when building field object
|
||||
:return: Pydantic `BaseModel` type
|
||||
"""
|
||||
model_name = json_schema.get("title")
|
||||
field_definitions = {}
|
||||
for name, prop in json_schema.get("properties", {}).items():
|
||||
updated_name, pydantic_type, pydantic_field = json_schema_to_pydantic_field(
|
||||
name,
|
||||
prop,
|
||||
json_schema.get("required", []),
|
||||
skip_default=skip_default,
|
||||
)
|
||||
field_definitions[updated_name] = (pydantic_type, pydantic_field)
|
||||
return create_model(model_name, **field_definitions) # type: ignore
|
||||
|
||||
|
||||
def pydantic_model_from_param_schema(param_schema: t.Dict) -> t.Type:
|
||||
"""
|
||||
Dynamically creates a Pydantic model from a schema dictionary.
|
||||
|
||||
:param param_schema: Schema with 'title', 'properties', and optionally 'required' keys.
|
||||
:return: A Pydantic model class for the defined schema.
|
||||
|
||||
:raised ValueError: Invalid 'type' for property or recursive model creation.
|
||||
|
||||
Note: Requires global `schema_type_python_type_dict` for type mapping and
|
||||
`fallback_values` for default values.
|
||||
"""
|
||||
required_fields = {}
|
||||
optional_fields = {}
|
||||
if "title" not in param_schema:
|
||||
raise ValueError(f"Missing 'title' in param_schema: {param_schema}")
|
||||
|
||||
param_title = str(param_schema["title"]).replace(" ", "")
|
||||
required_props = param_schema.get("required", [])
|
||||
|
||||
if param_schema.get("type") == "array":
|
||||
# print("param_schema inside array - ", param_schema)
|
||||
item_schema = param_schema.get("items")
|
||||
if item_schema:
|
||||
ItemType = t.cast(
|
||||
t.Type,
|
||||
json_schema_to_pydantic_type(
|
||||
json_schema=item_schema,
|
||||
),
|
||||
)
|
||||
return t.List[ItemType] # type: ignore
|
||||
return t.List
|
||||
|
||||
for prop_name, prop_info in param_schema.get("properties", {}).items():
|
||||
prop_type = prop_info.get("type")
|
||||
prop_title = prop_info.get("title", prop_name).replace(" ", "")
|
||||
prop_default = prop_info.get("default", FALLBACK_VALUES.get(prop_type))
|
||||
if (
|
||||
prop_type is not None
|
||||
and prop_type in PYDANTIC_TYPE_TO_PYTHON_TYPE
|
||||
and prop_type not in CONTAINER_TYPE
|
||||
):
|
||||
signature_prop_type = PYDANTIC_TYPE_TO_PYTHON_TYPE[prop_type]
|
||||
elif prop_type is None:
|
||||
# Schema uses anyOf/allOf/oneOf/$ref instead of a top-level "type" key.
|
||||
# Delegate to json_schema_to_pydantic_type which handles all combiners.
|
||||
signature_prop_type = t.cast(
|
||||
t.Type,
|
||||
json_schema_to_pydantic_type(json_schema=prop_info),
|
||||
)
|
||||
else:
|
||||
signature_prop_type = pydantic_model_from_param_schema(prop_info)
|
||||
|
||||
field_kwargs = {
|
||||
"description": prop_info.get(
|
||||
"description", prop_info.get("desc", prop_title)
|
||||
),
|
||||
}
|
||||
|
||||
# Add alias if the field name is a reserved Pydantic name
|
||||
if prop_name in reserved_names:
|
||||
field_kwargs["alias"] = prop_name
|
||||
field_kwargs["title"] = f"{prop_name}_"
|
||||
else:
|
||||
field_kwargs["title"] = prop_title
|
||||
|
||||
if prop_name in required_props:
|
||||
required_fields[prop_name] = (
|
||||
signature_prop_type,
|
||||
Field(..., **field_kwargs),
|
||||
)
|
||||
else:
|
||||
optional_fields[prop_name] = (
|
||||
signature_prop_type,
|
||||
Field(default=prop_default, **field_kwargs),
|
||||
)
|
||||
|
||||
if not required_fields and not optional_fields:
|
||||
return t.Dict
|
||||
|
||||
return create_model( # type: ignore
|
||||
param_title,
|
||||
**required_fields,
|
||||
**optional_fields,
|
||||
)
|
||||
|
||||
|
||||
def get_signature_format_from_schema_params(
|
||||
schema_params: t.Dict,
|
||||
skip_default: bool = False,
|
||||
) -> t.List[Parameter]:
|
||||
"""
|
||||
Get function parameters signature(with pydantic field definition as default values)
|
||||
from schema parameters. Works like:
|
||||
|
||||
def demo_function(
|
||||
owner: str,
|
||||
repo: str),
|
||||
)
|
||||
|
||||
:param schema_params: A dictionary object containing schema params, with keys [properties, required etc.].
|
||||
:return: List of required and optional parameters
|
||||
|
||||
Output Format:
|
||||
[
|
||||
<Parameter "owner: str">,
|
||||
<Parameter "repo: str">
|
||||
]
|
||||
"""
|
||||
default_parameters = []
|
||||
none_default_parameters = []
|
||||
|
||||
required_params = schema_params.get("required", [])
|
||||
schema_params_object = schema_params.get("properties", {})
|
||||
for param_name, param_schema in schema_params_object.items():
|
||||
param_type = param_schema.get("type", None)
|
||||
param_oneOf = param_schema.get("oneOf", None)
|
||||
param_anyOf = param_schema.get("anyOf", None)
|
||||
param_allOf = param_schema.get("allOf", None)
|
||||
if param_allOf is not None and len(param_allOf) == 1:
|
||||
param_type = param_allOf[0].get("type", None)
|
||||
if param_oneOf is not None or param_anyOf is not None:
|
||||
param_types = [ptype.get("type") for ptype in (param_oneOf or param_anyOf)]
|
||||
# Map each option to a Python type, falling back to t.Any for options
|
||||
# that are missing a "type" key or use an unrecognized type, then build
|
||||
# a Union for any count of members (no 1/2/3-member cap).
|
||||
mapped_types: t.List[t.Any] = [
|
||||
PYDANTIC_TYPE_TO_PYTHON_TYPE.get(ptype, t.Any) for ptype in param_types
|
||||
]
|
||||
if len(mapped_types) == 1:
|
||||
annotation = mapped_types[0]
|
||||
else:
|
||||
annotation = reduce(lambda a, b: t.Union[a, b], mapped_types)
|
||||
param_default = param_schema.get("default", "")
|
||||
elif param_type in PYDANTIC_TYPE_TO_PYTHON_TYPE:
|
||||
annotation = PYDANTIC_TYPE_TO_PYTHON_TYPE[param_type]
|
||||
param_default = param_schema.get("default", FALLBACK_VALUES[param_type])
|
||||
else:
|
||||
annotation = pydantic_model_from_param_schema(param_schema)
|
||||
if param_type is None or param_type == "null":
|
||||
param_default = None
|
||||
else:
|
||||
param_default = param_schema.get("default", FALLBACK_VALUES[param_type])
|
||||
|
||||
default = param_default
|
||||
required = param_schema.get("required", False) or param_name in required_params
|
||||
if required:
|
||||
default = Parameter.empty
|
||||
|
||||
if skip_default:
|
||||
default = Parameter.empty
|
||||
|
||||
parameter = Parameter(
|
||||
name=param_name,
|
||||
kind=Parameter.POSITIONAL_OR_KEYWORD,
|
||||
annotation=annotation,
|
||||
default=default,
|
||||
)
|
||||
if required:
|
||||
default_parameters.append(parameter)
|
||||
continue
|
||||
none_default_parameters.append(parameter)
|
||||
return default_parameters + none_default_parameters
|
||||
|
||||
|
||||
def get_pydantic_signature_format_from_schema_params(
|
||||
schema_params: t.Dict,
|
||||
skip_default: bool = False,
|
||||
) -> t.List[Parameter]:
|
||||
"""
|
||||
Get function parameters signature(with pydantic field definition as default values)
|
||||
from schema parameters. Works like:
|
||||
|
||||
def demo_function(
|
||||
owner: str=Field(..., description='The account owner of the repository.'),
|
||||
repo: str=Field(..., description='The name of the repository without the `.git` extension.'),
|
||||
)
|
||||
|
||||
:param schema_params: A dictionary object containing schema params, with keys [properties, required etc.].
|
||||
:return: List of required and optional parameters
|
||||
|
||||
Example Output Format:
|
||||
```python
|
||||
[
|
||||
<Parameter "owner: str = FieldInfo(
|
||||
default=Ellipsis,
|
||||
description='The account owner of the repository.',
|
||||
extra={'examples': ([],)})">,
|
||||
<Parameter "repo: str = FieldInfo(
|
||||
default=Ellipsis,
|
||||
description='The name of the repository without the `.git` extension.',
|
||||
extra={'examples': ([],)})">
|
||||
]
|
||||
```
|
||||
"""
|
||||
all_parameters = []
|
||||
field_definitions = json_schema_to_fields_dict(schema_params)
|
||||
for param_name, (param_dtype, parame_field) in field_definitions.items():
|
||||
param = Parameter(
|
||||
name=param_name,
|
||||
kind=Parameter.POSITIONAL_OR_KEYWORD,
|
||||
annotation=param_dtype,
|
||||
default=Parameter.empty if skip_default else parame_field.default,
|
||||
)
|
||||
all_parameters.append(param)
|
||||
|
||||
return all_parameters
|
||||
|
||||
|
||||
def generate_request_id() -> str:
|
||||
"""Generate a unique request ID."""
|
||||
return str(uuid.uuid4())
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Utilities for handling toolkit versions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import typing as t
|
||||
|
||||
from composio.core.types import ToolkitVersion, ToolkitVersionParam, ToolkitVersions
|
||||
|
||||
|
||||
def normalize_toolkit_slug(toolkit_slug: str) -> str:
|
||||
"""
|
||||
Canonicalizes a toolkit slug into the form used as a version-map key.
|
||||
|
||||
Toolkit slugs are matched case-insensitively. This is the single source of
|
||||
truth for that rule: every write into a version map (env vars, user-supplied
|
||||
dicts) and every read out of one MUST go through this helper so the two sides
|
||||
can never drift apart and silently miss a configured pin.
|
||||
|
||||
Kept intentionally equivalent to the TypeScript SDK's ``normalizeToolkitSlug``
|
||||
(see ts/packages/core/src/utils/toolkitVersion.ts).
|
||||
|
||||
:param toolkit_slug: The slug/name of the toolkit, in any casing
|
||||
:return: The normalized (lowercase) slug used as a version-map key
|
||||
"""
|
||||
return toolkit_slug.lower()
|
||||
|
||||
|
||||
def get_toolkit_version(
|
||||
toolkit_slug: str, toolkit_versions: t.Optional[ToolkitVersionParam] = None
|
||||
) -> ToolkitVersion:
|
||||
"""
|
||||
Gets the version for a specific toolkit based on the provided toolkit versions configuration.
|
||||
|
||||
:param toolkit_slug: The slug/name of the toolkit to get the version for
|
||||
:param toolkit_versions: Optional toolkit versions configuration (string for global version
|
||||
or dict mapping toolkit slugs to versions)
|
||||
:return: The toolkit version to use - either the specific version from config, or 'latest' as fallback
|
||||
"""
|
||||
# If toolkit_versions is a string, use it as a global version for all toolkits
|
||||
if isinstance(toolkit_versions, str):
|
||||
return toolkit_versions
|
||||
|
||||
# If toolkit_versions is a dict mapping, look up the specific toolkit version.
|
||||
# The map is keyed by normalized slugs, so normalize the lookup too
|
||||
# (see normalize_toolkit_slug for why).
|
||||
if isinstance(toolkit_versions, dict) and len(toolkit_versions) > 0:
|
||||
return toolkit_versions.get(normalize_toolkit_slug(toolkit_slug), "latest")
|
||||
|
||||
# Else use 'latest'
|
||||
return "latest"
|
||||
|
||||
|
||||
def get_toolkit_versions(
|
||||
default_versions: t.Optional[ToolkitVersionParam] = None,
|
||||
) -> ToolkitVersionParam:
|
||||
"""
|
||||
Gets toolkit versions configuration by merging environment variables, user-provided defaults, and fallbacks.
|
||||
|
||||
Priority order:
|
||||
1. If default_versions is a string, use it as a global version for all toolkits
|
||||
2. User-provided toolkit version mappings (default_versions dict)
|
||||
3. Environment variables (COMPOSIO_TOOLKIT_VERSION_<TOOLKIT_NAME>)
|
||||
4. Fallback to 'latest' if no versions are configured
|
||||
|
||||
:param default_versions: Optional default versions configuration (string for global version or dict mapping toolkit names to versions)
|
||||
:return: Toolkit versions configuration - either a string for global version or dict mapping toolkit names to versions
|
||||
"""
|
||||
# If already set by user as a string, use it as global version for all toolkits
|
||||
if isinstance(default_versions, str):
|
||||
return default_versions
|
||||
|
||||
# Check if there are envs similar to COMPOSIO_TOOLKIT_VERSION_GITHUB then extract the toolkit name
|
||||
toolkit_versions_from_env: ToolkitVersions = {}
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith("COMPOSIO_TOOLKIT_VERSION_"):
|
||||
toolkit_name = key.replace("COMPOSIO_TOOLKIT_VERSION_", "")
|
||||
toolkit_versions_from_env[normalize_toolkit_slug(toolkit_name)] = value
|
||||
|
||||
# Normalize keys via normalize_toolkit_slug (the same helper the lookup uses);
|
||||
# user-provided values override env.
|
||||
user_provided_toolkit_versions: ToolkitVersions = {}
|
||||
if default_versions and isinstance(default_versions, dict):
|
||||
user_provided_toolkit_versions = {
|
||||
normalize_toolkit_slug(key): value
|
||||
for key, value in default_versions.items()
|
||||
}
|
||||
|
||||
# Final toolkit versions
|
||||
toolkit_versions = {
|
||||
**toolkit_versions_from_env,
|
||||
**user_provided_toolkit_versions,
|
||||
}
|
||||
|
||||
# If the toolkit_versions are empty, use 'latest'
|
||||
if len(toolkit_versions) == 0:
|
||||
return "latest"
|
||||
|
||||
return toolkit_versions
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Allowlist enforcement for automatic file upload during tool execution.
|
||||
|
||||
This is only consulted when
|
||||
``dangerously_allow_auto_upload_download_files=True``. Manual upload APIs are
|
||||
not subject to the allowlist (parity with the TypeScript SDK).
|
||||
|
||||
- A local path is accepted iff its symlink-resolved absolute path is inside one
|
||||
of the allowed directories on a path-component boundary. ``/tmp/foo`` allows
|
||||
``/tmp/foo/bar`` but NOT ``/tmp/foo-bar``.
|
||||
- URLs never hit this check.
|
||||
- User-provided ``file_upload_dirs`` fully REPLACES the default
|
||||
``[~/.composio/temp]``.
|
||||
- On Windows, path comparisons are case-insensitive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
from composio.exceptions import (
|
||||
FileUploadPathNotAllowedError,
|
||||
SDKFileNotFoundError,
|
||||
)
|
||||
|
||||
UPLOAD_TEMP_DIRECTORY_NAME = "temp"
|
||||
|
||||
|
||||
def get_default_upload_dir() -> t.Optional[Path]:
|
||||
"""Absolute path of the default upload staging directory
|
||||
(``<home>/.composio/temp``), or None when a home directory cannot be
|
||||
determined."""
|
||||
try:
|
||||
home = Path.home()
|
||||
except (RuntimeError, OSError):
|
||||
return None
|
||||
return (home / ".composio" / UPLOAD_TEMP_DIRECTORY_NAME).resolve()
|
||||
|
||||
|
||||
def ensure_dir_exists(p: Path) -> None:
|
||||
"""Best-effort directory creation. Allowlist enforcement still works if
|
||||
creation fails (e.g. read-only filesystem)."""
|
||||
try:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
FileUploadDirs = t.Union[t.Sequence[str], t.Literal[False], None]
|
||||
"""User-facing type for ``file_upload_dirs``.
|
||||
|
||||
- ``None`` (default) -> use ``[<home>/.composio/temp]``.
|
||||
- ``False`` -> reject every local path during auto-upload (URLs / bytes still
|
||||
work).
|
||||
- ``[]`` -> same as ``False`` (kept as an alias; prefer ``False``).
|
||||
- ``Sequence[str]`` (non-empty) -> allowlist replacing the default.
|
||||
"""
|
||||
|
||||
|
||||
def resolve_effective_upload_allowlist(
|
||||
user_dirs: FileUploadDirs,
|
||||
) -> t.List[Path]:
|
||||
"""Resolve the effective allowlist.
|
||||
|
||||
- ``None`` -> ``[<default>]`` when available, else ``[]`` (fail-closed).
|
||||
- ``False`` -> ``[]`` (explicit "reject all local paths"). URLs and
|
||||
in-memory bytes still work because they aren't path-checked.
|
||||
- ``Sequence[str]`` (including ``[]``) REPLACES the default and is returned
|
||||
verbatim after ``~``-expansion and ``resolve()``. ``[]`` is treated as
|
||||
equivalent to ``False``.
|
||||
- Empty / blank / non-string entries are skipped.
|
||||
- Duplicates are removed (case-insensitive on Windows).
|
||||
"""
|
||||
if user_dirs is None:
|
||||
default = get_default_upload_dir()
|
||||
return [default] if default is not None else []
|
||||
if user_dirs is False:
|
||||
return []
|
||||
|
||||
seen: t.Set[str] = set()
|
||||
out: t.List[Path] = []
|
||||
for raw in user_dirs:
|
||||
if not isinstance(raw, (str, os.PathLike)):
|
||||
continue
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
continue
|
||||
abs_path = Path(s).expanduser().resolve()
|
||||
key = str(abs_path).lower() if sys.platform == "win32" else str(abs_path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(abs_path)
|
||||
return out
|
||||
|
||||
|
||||
def _is_inside_dir(child: Path, parent: Path) -> bool:
|
||||
"""True iff ``child`` equals ``parent`` or is nested inside on a component
|
||||
boundary. Assumes both are absolute and normalized."""
|
||||
try:
|
||||
child_str = str(child)
|
||||
parent_str = str(parent)
|
||||
if sys.platform == "win32":
|
||||
child_str = child_str.lower()
|
||||
parent_str = parent_str.lower()
|
||||
if child_str == parent_str:
|
||||
return True
|
||||
sep = os.sep
|
||||
parent_with_sep = parent_str if parent_str.endswith(sep) else parent_str + sep
|
||||
return child_str.startswith(parent_with_sep)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _format_allowlist(dirs: t.Sequence[Path]) -> str:
|
||||
if not dirs:
|
||||
return " (none configured — all local paths are blocked)"
|
||||
return "\n".join(f" - {d}" for d in dirs)
|
||||
|
||||
|
||||
def _build_help_footer(allowlist: t.Sequence[Path]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"",
|
||||
"Allowed upload directories (file_upload_dirs):",
|
||||
_format_allowlist(allowlist),
|
||||
"",
|
||||
"Fix one of the following:",
|
||||
"",
|
||||
" 1. Recommended — move the file under an allowed directory, or add your",
|
||||
" directory to the allowlist:",
|
||||
" Composio(",
|
||||
" file_upload_dirs=['/abs/path/to/uploads', '~/.composio/temp'],",
|
||||
" )",
|
||||
" Note: user-provided `file_upload_dirs` REPLACES the default",
|
||||
" `~/.composio/temp`. Include it explicitly if you want staged uploads",
|
||||
" to keep working.",
|
||||
"",
|
||||
" 2. Pass the file by URL (http://... / https://...) instead of a",
|
||||
" filesystem path. URLs are never path-checked.",
|
||||
"",
|
||||
" 3. (Dangerous) Bypass the allowlist entirely by opting into automatic",
|
||||
" file upload/download from any readable path:",
|
||||
" Composio(dangerously_allow_auto_upload_download_files=True, ...)",
|
||||
" WARNING: This lets a prompt-injected or misbehaving tool read ANY",
|
||||
" file the process can access. Only enable it in trusted, sandboxed",
|
||||
" environments. The built-in sensitive-path denylist (.ssh, .aws,",
|
||||
" .env, private SSH keys, etc.) still applies unless you also set",
|
||||
" `sensitive_file_upload_protection=False`.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def assert_path_inside_upload_dirs(
|
||||
file_path: t.Union[str, Path],
|
||||
allowlist: t.Sequence[Path],
|
||||
) -> None:
|
||||
"""Raise an elaborate error if the path is missing or outside the allowlist.
|
||||
|
||||
:raises SDKFileNotFoundError: when ``file_path`` does not exist on disk.
|
||||
:raises FileUploadPathNotAllowedError: when resolved path is outside every
|
||||
entry of ``allowlist`` (including when allowlist is empty).
|
||||
"""
|
||||
attempted = str(file_path)
|
||||
abs_path = Path(attempted).expanduser()
|
||||
try:
|
||||
abs_path = abs_path.resolve(strict=False)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not abs_path.exists():
|
||||
cwd = Path.cwd()
|
||||
parent = abs_path.parent
|
||||
parent_exists = parent.exists()
|
||||
raise SDKFileNotFoundError(
|
||||
"\n".join(
|
||||
[
|
||||
f'Refusing to auto-upload "{attempted}": the file does not exist on disk.',
|
||||
"",
|
||||
f"Path attempted: {attempted}",
|
||||
f"Resolved to: {abs_path}",
|
||||
f"Process cwd: {cwd}",
|
||||
f"Parent exists: {'yes (' + str(parent) + ')' if parent_exists else 'no (' + str(parent) + ')'}",
|
||||
"",
|
||||
"Common causes:",
|
||||
" - Typo in the filename passed to the tool.",
|
||||
" - Relative path resolved against the wrong working directory",
|
||||
" (relative paths use os.getcwd() at the moment of upload).",
|
||||
" - File was deleted between the tool being called and the upload starting.",
|
||||
"",
|
||||
_build_help_footer(allowlist),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
real_path = abs_path.resolve(strict=True)
|
||||
except OSError:
|
||||
real_path = abs_path
|
||||
|
||||
if not allowlist:
|
||||
raise FileUploadPathNotAllowedError(
|
||||
"\n".join(
|
||||
[
|
||||
f'Refusing to auto-upload "{attempted}": no upload directories are configured.',
|
||||
"",
|
||||
f"Path attempted: {attempted}",
|
||||
f"Resolved to: {real_path}",
|
||||
"",
|
||||
"Automatic file upload during tool execution is locked down by default",
|
||||
"to prevent a prompt-injected tool from exfiltrating server files",
|
||||
"(source code, .env, SSH keys, etc.).",
|
||||
_build_help_footer(allowlist),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
for dir_entry in allowlist:
|
||||
try:
|
||||
real_dir = Path(dir_entry).expanduser().resolve(strict=False)
|
||||
except OSError:
|
||||
real_dir = Path(dir_entry)
|
||||
if _is_inside_dir(real_path, real_dir):
|
||||
return
|
||||
|
||||
raise FileUploadPathNotAllowedError(
|
||||
"\n".join(
|
||||
[
|
||||
f'Refusing to auto-upload "{attempted}": resolved path is not inside any',
|
||||
"directory in the configured `file_upload_dirs` allowlist.",
|
||||
"",
|
||||
f"Path attempted: {attempted}",
|
||||
f"Resolved to: {real_path}",
|
||||
_build_help_footer(allowlist),
|
||||
]
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""UUID utility functions for Composio SDK."""
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""Generate a random UUID v4 string."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def generate_short_id() -> str:
|
||||
"""
|
||||
Generate a short ID (8 characters) from a UUID.
|
||||
|
||||
Returns the first 8 characters of a UUID with dashes removed.
|
||||
"""
|
||||
return generate_uuid()[:8].replace("-", "")
|
||||
Reference in New Issue
Block a user