chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
############################
|
||||
## Project Specific Items ##
|
||||
############################
|
||||
try.ipynb
|
||||
refactor_debug.ipynb
|
||||
temp
|
||||
|
||||
.vscode
|
||||
#############################################
|
||||
## From @Github/gitignore/Python.gitignore ##
|
||||
#############################################
|
||||
# Byte-compiled / optimized / DLL files
|
||||
#
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "client"]
|
||||
path = client
|
||||
url = git@github.com:composiohq/composio-base-py
|
||||
@@ -0,0 +1,42 @@
|
||||
# AGENTS.md
|
||||
|
||||
Python SDK guidance for AI agents.
|
||||
|
||||
## Scope
|
||||
|
||||
`python/` contains the Python SDK, provider packages, tests, nox sessions, release scripts, and Python docs.
|
||||
|
||||
## Skill Routing
|
||||
|
||||
- Use `python-sdk` for core SDK code under `python/composio/`.
|
||||
- Use `python-providers` for `python/providers/*`.
|
||||
- Use `python-testing` for Ruff, mypy, pytest, nox, and Makefile verification.
|
||||
- Use `python-release` for build, bump, and publishing workflow changes.
|
||||
- Use `cross-sdk-parity` when matching TypeScript SDK behavior.
|
||||
|
||||
## Setup
|
||||
|
||||
Run from `python/`:
|
||||
|
||||
```bash
|
||||
make env
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make fmt
|
||||
make chk
|
||||
make tst
|
||||
make snt
|
||||
make type_inference
|
||||
make build
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Use Ruff for formatting/linting and mypy for type checks.
|
||||
- Add pytest coverage for behavior changes.
|
||||
- Keep provider-specific changes under the relevant `python/providers/<provider>/` package.
|
||||
- When bumping `composio-client`, update `python/pyproject.toml`, `python/setup.py`, and root `uv.lock` together.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Composio Python SDK will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> Versions between `0.8.11` and `0.13.0` were released without CHANGELOG entries. See the [Git commit log](https://github.com/ComposioHQ/composio/commits/next/python) for changes in that window.
|
||||
|
||||
## [0.17.1] - 2026-06-28
|
||||
|
||||
### Added
|
||||
- **Tool Router session deletion**: delete sessions from Python via `composio.sessions.delete(session_id)` or `session.delete()`, matching the TypeScript SDK surface.
|
||||
|
||||
### Changed
|
||||
- Bumped the Python SDK and provider packages from `0.17.0` to `0.17.1`.
|
||||
|
||||
## [0.17.0] - 2026-06-26
|
||||
|
||||
### Added
|
||||
- **`triggers.parse()`**: parse and optionally verify an incoming webhook request, mirroring the TypeScript SDK. Pass `verify_secret` to validate the signature; omit it to skip verification explicitly. A present-but-empty `verify_secret` (e.g. an unset `COMPOSIO_WEBHOOK_SECRET`) raises rather than silently skipping verification.
|
||||
- **`triggers.set_webhook_subscription()`**: create or update the project webhook subscription from the Python SDK.
|
||||
- **`connected_accounts.update_acl()`**: the experimental shared-connection ACL patch helper graduated to `connected_accounts`; `experimental.update_acl()` remains as a deprecated alias.
|
||||
|
||||
### Changed
|
||||
- **`composio.sessions` is the canonical sessions entrypoint**: use `composio.sessions.create(...)` (or the `composio.create` / `composio.use` shortcuts). `composio.tool_router` is now a deprecated alias for the same object.
|
||||
- **MCP is now opt-in for sessions**: `composio.create()` / `composio.use()` return native-tool sessions by default and select the MCP-typed session only when `mcp=True`. The runtime object is unchanged.
|
||||
- **Prefer `sandbox` for session code-execution config**: the `sandbox` key is now preferred; the existing `workbench` key is still accepted as an alias.
|
||||
- Bumped the Python SDK and provider packages from `0.16.0` to `0.17.0`.
|
||||
|
||||
## [0.16.0] - 2026-06-25
|
||||
|
||||
### Fixed
|
||||
- **Auto file upload/download behind `$ref`/`$defs`**: tool schemas that express their file fields through a `$ref`/`$defs` indirection are now resolved before file handling, so `file_uploadable` inputs and `file_downloadable` outputs reachable only through a reference are no longer silently skipped when `dangerously_allow_auto_upload_download_files=True`. `GMAIL_GET_ATTACHMENT` (whose `file_downloadable` flag sits two `$ref` hops deep) is the canonical case. Adds a cycle-safe, depth-capped `dereference_json_schema` utility with Draft 2020-12 sibling-keyword merge, mirroring the TypeScript SDK's `dereferenceJsonSchema`.
|
||||
- **No retries on non-idempotent tool writes**: `tools.execute()` and `tools.proxy()` are non-idempotent POST writes, but the Stainless-generated client retried them by default (on read timeouts, 429s, and 5xx), which could duplicate side effects — e.g. send an email twice. Both paths now route through a cached `client.without_retries` clone (`max_retries=0`); idempotent reads and lists keep the default retries. This is an interim fix ahead of backend idempotency keys.
|
||||
- **Bounded timeouts on file transfers**: session file uploads/downloads and the presigned S3 `PUT`/`GET` paths now use bounded `(connect, read)` timeouts, and timeout or request failures surface as the SDK's typed file errors instead of hanging.
|
||||
- **Provider-visible tool schema aliasing**: centralized aliasing via `alias_tool_input_schema` / `restore_tool_arguments`, applied across the Anthropic, Claude Agent SDK, Gemini, Google ADK, and OpenAI Agents providers. Aliases invalid Python parameter names (e.g. `$top`), preserves the existing `from` → `from_rs` style, avoids leading-underscore aliases so Pydantic-backed providers can build models, caps aliases at 64 characters for Anthropic-style validators, and dereferences internal `$ref`/`$defs` before aliasing. Original backend argument names are restored before execution.
|
||||
- **OpenAI Agents schema mutation**: the tool schema is now deep-copied before its `examples`/`default` fields are stripped, so the source schema is no longer mutated in place.
|
||||
|
||||
### Changed
|
||||
- Bumped the `composio-client` dependency from `1.39.0` → `1.41.0`.
|
||||
- Bumped the Python SDK and provider packages from `0.15.0` to `0.16.0`.
|
||||
- Refreshed Python core and provider dependency ranges, plus root and provider uv locks, to the newest compatible versions.
|
||||
|
||||
## [0.15.0] - 2026-06-16
|
||||
|
||||
### Fixed
|
||||
- Forwarded `user_id` from `triggers.create(..., user_id=...)` into the trigger upsert request via `extra_body`, so 2FA-enabled trigger projects can verify the pinned connected account belongs to the caller.
|
||||
|
||||
### Changed
|
||||
- Bumped the Python SDK and provider packages from `0.14.0` to `0.15.0`.
|
||||
|
||||
## [0.14.0] - 2026-06-05
|
||||
|
||||
### Removed
|
||||
- Removed the legacy `composio.tools.custom_tool` registry path, including `core.models.custom_tools`, `ExecuteRequestFn`, the old fallback execution wiring, the old security tests, and the legacy custom-tools example.
|
||||
|
||||
### Changed
|
||||
- Kept custom tools on the 2026 tool-router surface: `composio.experimental.tool()`, `composio.experimental.Toolkit`, inline execution, preload/attach/use flows, and `session.custom_tools()`.
|
||||
- Bumped the Python SDK and provider packages from `0.13.1` to `0.14.0`.
|
||||
|
||||
## [0.13.1] - 2026-05-13
|
||||
|
||||
### Added
|
||||
- **SHARED connected accounts (experimental)**: Connections can now be marked SHARED at create time and reached from a tool-router session by other `user_id`s when explicitly pinned and authorised via a per-user ACL. Mirrors the TypeScript SDK's `@composio/core@0.10.0` release.
|
||||
- `composio.connected_accounts.link(..., experimental={"account_type": "SHARED", "acl_config_for_shared": {...}})` to create.
|
||||
- `composio.experimental.update_acl(nanoid, allow_all_users=..., allowed_user_ids=[...], not_allowed_user_ids=[...])` to update the per-user ACL on an existing SHARED connection. PATCH semantics: omit a field to leave it unchanged; pass `[]` to clear an allow/deny list.
|
||||
- `session.authorize(toolkit, experimental={...})` for the same flow inside a tool-router session.
|
||||
- `composio.connected_accounts.list(account_type='PRIVATE' | 'SHARED' | 'ALL')` filter.
|
||||
- New typed errors: `ComposioAclOnlyForSharedError`, `ComposioSharedAccessDeniedError`, `ComposioSharedConnectionNotAccessibleError`.
|
||||
- **Experimental — shape may change in future releases.** Pinning a SHARED connection in a session config and direct execute by `connected_account_id` are unchanged.
|
||||
- **`composio.experimental` namespace**: new module housing experimental SDK methods that take a client (`update_acl`) alongside the existing decorators (`tool`, `Toolkit`).
|
||||
|
||||
### Fixed
|
||||
- **`initiate()` deprecation warning false-positives**: the legacy `POST /api/v3/connected_accounts` deprecation warning is now gated on the response `Deprecation` HTTP header (RFC 9745) rather than the `auth_scheme` of the request. Custom auth configs and non-OAuth schemes no longer trigger a spurious warning. The legacy endpoint's retired-path 400 is also surfaced as a typed `ComposioLegacyConnectedAccountsEndpointRetiredError`. See https://docs.composio.dev/docs/changelog/2026/04/24.
|
||||
- **Schema converter nullability**: `null`-only `anyOf` blocks now preserve nullability instead of dropping to a bare `str` fallback.
|
||||
|
||||
### Changed
|
||||
- Bumped `composio-client` dependency from `1.36.0` → `1.39.0` so the generated typed client carries the `Experimental` TypedDicts for the SHARED-connection surface.
|
||||
|
||||
## [0.8.11] - 2025-09-10
|
||||
|
||||
### Added
|
||||
- **Composio Connect Link Support**: New link() method for creating external authentication links
|
||||
- Added `link()` method to ConnectedAccounts class for generating user authentication links
|
||||
- Support for callback URL redirection after authentication
|
||||
- Enhanced user experience with external link-based authentication flow
|
||||
- Includes comprehensive documentation and usage examples
|
||||
|
||||
### Fixed
|
||||
- **Documentation**: Fixed typo in connected account creation function docstring
|
||||
- Corrected "coneected" to "connected" in function documentation
|
||||
|
||||
## [0.8.10] - 2024-12-19
|
||||
|
||||
### Added
|
||||
- **LlamaIndex Provider**: New LlamaIndex provider for enhanced AI framework integration
|
||||
- Complete provider implementation with demo and documentation (`python/providers/llamaindex/`)
|
||||
- Support for LlamaIndex-specific tool execution patterns
|
||||
- Includes provider.py, demo script, and proper packaging setup
|
||||
|
||||
### Changed
|
||||
- **Schema Parsing**: Improved OpenAPI schema parsing with default fallback to 'any' type for invalid schemas
|
||||
- Enhanced `python/composio/utils/openapi.py` to handle malformed schemas gracefully
|
||||
- More robust type inference for edge cases where schema type is missing or invalid
|
||||
|
||||
### Fixed
|
||||
- **Type Checking**: Fixed type checking issues in core validation logic
|
||||
- Updated `python/composio/core/models/toolkits.py` for better type safety
|
||||
- Added proper type annotations and validation
|
||||
- **Sentinel Value Checks**: Fixed sentinel value validation in core models
|
||||
- Enhanced validation in `python/composio/core/models/toolkits.py`
|
||||
- Improved handling in `python/composio/core/models/tools.py` and `python/composio/core/models/triggers.py`
|
||||
- Added proper sentinel value checks in `python/composio/types.py`
|
||||
- **Build System**: Updated noxfile.py for improved development workflow
|
||||
|
||||
## [0.8.9] - 2024-11-XX
|
||||
|
||||
### Added
|
||||
- Initial stable release with core functionality
|
||||
- Support for multiple AI frameworks (OpenAI, Anthropic, Google, etc.)
|
||||
- Comprehensive tool execution capabilities
|
||||
- Authentication and connected account management
|
||||
- File upload and download support
|
||||
- Webhook and trigger system
|
||||
- Multi-provider support architecture
|
||||
|
||||
### Features
|
||||
- Tool discovery and execution
|
||||
- Connected account management
|
||||
- Authentication configuration
|
||||
- File handling and processing
|
||||
- Webhook integration
|
||||
- Trigger system for automated workflows
|
||||
- Support for custom tools and integrations
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
For detailed commit history and technical changes, see the [Git commit log](https://github.com/composio/composio/commits/main).
|
||||
|
||||
## Support
|
||||
|
||||
For questions, issues, or contributions, please visit:
|
||||
- [GitHub Repository](https://github.com/composio/composio)
|
||||
- [Documentation](https://docs.composio.dev)
|
||||
- [Community Discord](https://discord.gg/composio)
|
||||
@@ -0,0 +1,82 @@
|
||||
.PHONY: clean-build sync provider create-provider env fmt chk snt tst type_inference dead-code bump build format check sanity test
|
||||
|
||||
clean-build:
|
||||
@rm -rf dist/ build/
|
||||
@for provider in $(shell ls -d providers/*); do\
|
||||
rm -rf build $$provider/dist $$provider/build;\
|
||||
done
|
||||
|
||||
sync:
|
||||
@uv sync
|
||||
@uv pip install -e .
|
||||
|
||||
provider:
|
||||
@for provider in $(shell ls -d providers/*); do\
|
||||
uv pip install $$provider;\
|
||||
done
|
||||
|
||||
create-provider:
|
||||
@if [ -z "$(name)" ]; then\
|
||||
echo "Please provide a provider name: make create-provider name=<provider-name> [agentic=true] [output=<directory>]";\
|
||||
exit 1;\
|
||||
fi
|
||||
@ARGS="$(name)";\
|
||||
if [ "$(agentic)" = "true" ]; then\
|
||||
ARGS="$$ARGS --agentic";\
|
||||
fi;\
|
||||
if [ ! -z "$(output)" ]; then\
|
||||
ARGS="$$ARGS --output-dir $(output)";\
|
||||
fi;\
|
||||
bash scripts/create-provider.sh $$ARGS
|
||||
|
||||
env:
|
||||
@echo "* creating new environment"
|
||||
@if [ -z "$$VIRTUAL_ENV" ];\
|
||||
then\
|
||||
uv venv --seed --prompt composio --python 3.12;\
|
||||
uv sync;\
|
||||
uv sync --dev;\
|
||||
make provider;\
|
||||
uv pip install -e .;\
|
||||
echo "* enter virtual environment with all development dependencies now";\
|
||||
else\
|
||||
uv sync;\
|
||||
uv pip install -e .;\
|
||||
echo "* already in a virtual environment (exit first ('deactivate') to create a new environment)";\
|
||||
fi
|
||||
@echo "* run 'source .venv/bin/activate' to enter the development environment."
|
||||
|
||||
|
||||
fmt:
|
||||
@nox -s fmt
|
||||
|
||||
chk:
|
||||
@nox -s chk
|
||||
|
||||
dead-code:
|
||||
@nox -s dead_code
|
||||
|
||||
snt:
|
||||
@nox -s snt
|
||||
|
||||
tst:
|
||||
@nox -s tst
|
||||
|
||||
type_inference:
|
||||
@nox -s type_inference
|
||||
|
||||
# Friendly aliases for the short session names above (e.g. `make test` == `make tst`).
|
||||
format: fmt
|
||||
check: chk
|
||||
sanity: snt
|
||||
test: tst
|
||||
|
||||
bump: clean-build
|
||||
@uv run python scripts/bump.py
|
||||
|
||||
build: clean-build
|
||||
@./.venv/bin/python -m build
|
||||
@for provider in $(shell ls -d providers/*); do\
|
||||
./.venv/bin/python -m build $$provider;\
|
||||
cp $$provider/dist/* dist/;\
|
||||
done
|
||||
@@ -0,0 +1,141 @@
|
||||
<p align="center">
|
||||
<a href="https://composio.dev">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://brand.composio.dev/logos/Logomark-White.svg">
|
||||
<img alt="Composio logo" src="https://brand.composio.dev/logos/Logomark-Black.svg" width="96">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Composio Python SDK
|
||||
|
||||
Composio gives your AI agents 1000+ pre-authenticated toolkits, per-user sessions, authentication, triggers, and a sandbox. This package is the Python SDK.
|
||||
|
||||
- [Documentation](https://docs.composio.dev)
|
||||
- [Quickstart](https://docs.composio.dev/docs/quickstart)
|
||||
- [Dashboard](https://dashboard.composio.dev) (grab your `COMPOSIO_API_KEY` from [Settings](https://dashboard.composio.dev/settings))
|
||||
|
||||
## Install
|
||||
|
||||
Requires Python 3.10+.
|
||||
|
||||
```bash
|
||||
pip install composio
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
Create a session for one of your users and hand its tools to your agent:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio() # reads COMPOSIO_API_KEY, or pass api_key=...
|
||||
|
||||
session = composio.create(user_id="user_123")
|
||||
tools = session.tools() # OpenAI-format tool definitions by default
|
||||
```
|
||||
|
||||
By default a session gets meta tools that discover, authenticate, and execute app tools at runtime, so you don't load hundreds of tool definitions into context. Store `session.session_id` and reuse the session across turns:
|
||||
|
||||
```python
|
||||
session = composio.use(session_id)
|
||||
```
|
||||
|
||||
See [how Composio works](https://docs.composio.dev/docs/how-composio-works) for sessions and meta tools, and [configuring sessions](https://docs.composio.dev/docs/configuring-sessions) for restricting `toolkits`, `tools`, `auth_configs`, and `connected_accounts` on `composio.create()`.
|
||||
|
||||
## Use with an agent framework
|
||||
|
||||
Provider packages adapt `session.tools()` to your framework's native tool format. With [OpenAI Agents](https://github.com/ComposioHQ/composio/tree/next/python/providers/openai_agents):
|
||||
|
||||
```bash
|
||||
pip install composio composio-openai-agents openai-agents
|
||||
```
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
from composio_openai_agents import OpenAIAgentsProvider
|
||||
from agents import Agent, Runner
|
||||
|
||||
composio = Composio(provider=OpenAIAgentsProvider())
|
||||
|
||||
session = composio.create(user_id="user_123")
|
||||
tools = session.tools()
|
||||
|
||||
agent = Agent(
|
||||
name="Personal Assistant",
|
||||
instructions="You are a helpful assistant. Use Composio tools to take action.",
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
result = Runner.run_sync(starting_agent=agent, input="Summarize my emails from today")
|
||||
print(result.final_output)
|
||||
```
|
||||
|
||||
Other Python providers: [`composio-openai`](https://github.com/ComposioHQ/composio/tree/next/python/providers/openai), [`composio-anthropic`](https://github.com/ComposioHQ/composio/tree/next/python/providers/anthropic), [`composio-claude-agent-sdk`](https://github.com/ComposioHQ/composio/tree/next/python/providers/claude_agent_sdk), [`composio-langchain`](https://github.com/ComposioHQ/composio/tree/next/python/providers/langchain), [`composio-langgraph`](https://github.com/ComposioHQ/composio/tree/next/python/providers/langgraph), [`composio-llamaindex`](https://github.com/ComposioHQ/composio/tree/next/python/providers/llamaindex), [`composio-crewai`](https://github.com/ComposioHQ/composio/tree/next/python/providers/crewai), [`composio-autogen`](https://github.com/ComposioHQ/composio/tree/next/python/providers/autogen), [`composio-gemini`](https://github.com/ComposioHQ/composio/tree/next/python/providers/gemini), [`composio-google`](https://github.com/ComposioHQ/composio/tree/next/python/providers/google), [`composio-google-adk`](https://github.com/ComposioHQ/composio/tree/next/python/providers/google_adk). Don't see yours? [Build a custom provider](https://docs.composio.dev/docs/providers/custom-providers).
|
||||
|
||||
## MCP
|
||||
|
||||
Every session also exposes a hosted MCP endpoint. Pass `mcp=True` and point Claude, Cursor, or any MCP client at it:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
session = composio.create(user_id="user_123", mcp=True)
|
||||
print(session.mcp.url) # MCP endpoint for this session
|
||||
print(session.mcp.headers) # auth headers for the endpoint
|
||||
```
|
||||
|
||||
See [sessions via MCP](https://docs.composio.dev/docs/sessions-via-mcp).
|
||||
|
||||
## Authentication
|
||||
|
||||
Sessions manage connections for you by default; the agent walks the user through OAuth with the session's meta tools. To drive the flow yourself, authorize a toolkit from the session:
|
||||
|
||||
```python
|
||||
connection_request = session.authorize("gmail")
|
||||
print(connection_request.redirect_url) # send the user here to approve access
|
||||
connection_request.wait_for_connection()
|
||||
```
|
||||
|
||||
See [authentication](https://docs.composio.dev/docs/authentication) for auth configs, custom OAuth apps, and connection lifecycle.
|
||||
|
||||
## Triggers
|
||||
|
||||
Subscribe to events from connected apps (new email, new commit, and so on) and react to them:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
trigger = composio.triggers.create(
|
||||
slug="GITHUB_COMMIT_EVENT",
|
||||
user_id="user_123",
|
||||
trigger_config={"owner": "composiohq", "repo": "composio"},
|
||||
)
|
||||
|
||||
subscription = composio.triggers.subscribe()
|
||||
|
||||
@subscription.handle(trigger_id=trigger.trigger_id)
|
||||
def handle_event(data):
|
||||
print("Event received:", data)
|
||||
|
||||
subscription.wait_forever()
|
||||
```
|
||||
|
||||
`subscribe()` streams events over a WebSocket for local development. In production, register a webhook URL and parse deliveries with `composio.triggers.parse()`. See [setting up triggers](https://docs.composio.dev/docs/setting-up-triggers/creating-triggers).
|
||||
|
||||
## Development
|
||||
|
||||
This package lives in the [Composio SDK monorepo](https://github.com/ComposioHQ/composio) under `python/`. See the [contribution guidelines](https://github.com/ComposioHQ/composio/blob/next/CONTRIBUTING.md) to get set up.
|
||||
|
||||
## Support
|
||||
|
||||
- [Documentation](https://docs.composio.dev)
|
||||
- [Python SDK reference](https://docs.composio.dev/reference/sdk-reference/python)
|
||||
- [Discord community](https://discord.gg/composio)
|
||||
- [Open an issue](https://github.com/ComposioHQ/composio/issues)
|
||||
- [support@composio.dev](mailto:support@composio.dev)
|
||||
@@ -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("-", "")
|
||||
@@ -0,0 +1,7 @@
|
||||
comment:
|
||||
layout: "diff, flags, files"
|
||||
behavior: default
|
||||
require_changes: false
|
||||
require_base: false
|
||||
require_head: true
|
||||
hide_project_coverage: false
|
||||
@@ -0,0 +1,81 @@
|
||||
[mypy]
|
||||
; Exclude directories and files from type checking
|
||||
; - External provider type inference tests require those provider packages to be installed
|
||||
; The core test_type_inference.py and test_type_inference_custom_provider.py can be checked
|
||||
exclude = ^(apps/composio/|utils/|\.nox/|.*/setup\.py|.*/build/|examples/|.*demo.*|tests/test_type_inference_(?!custom_provider).*)
|
||||
strict_optional=True
|
||||
disable_error_code=var-annotated
|
||||
|
||||
|
||||
; Only add packages which does not have type-stub package
|
||||
[mypy-pysher.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-crewai.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-vertexai.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-google.adk.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-google.genai.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-proto.marshal.collections.maps]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-autogen.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-autogen_core.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-composio_gemini.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-claude_agent_sdk.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-mcp.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-llama_index.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-agents.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
; Composio provider packages (may not be installed)
|
||||
[mypy-composio_anthropic.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-composio_langchain.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-composio_langgraph.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-composio_llamaindex.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-composio_openai_agents.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-composio_crewai.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-composio_autogen.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-composio_google.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-composio_google_adk.*]
|
||||
ignore_missing_imports=true
|
||||
|
||||
[mypy-composio_claude_agent_sdk.*]
|
||||
ignore_missing_imports=true
|
||||
[mypy-json_schema_to_pydantic.*]
|
||||
ignore_missing_imports=true
|
||||
@@ -0,0 +1,8 @@
|
||||
[pytest]
|
||||
addopts = --strict-markers
|
||||
markers =
|
||||
core: marks tests that check only the core functionality
|
||||
openai: marks tests that check the OpenAIProvider
|
||||
langchain: marks tests that check the LangChainProvider
|
||||
agno: marks tests that check the AgnoProvider
|
||||
gemini: marks tests that check the GeminiProvider
|
||||
@@ -0,0 +1,6 @@
|
||||
# Same as Black.
|
||||
line-length = 88
|
||||
indent-width = 4
|
||||
|
||||
[lint]
|
||||
ignore = [ "E741" ]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Vulture allowlist — confirmed false positives.
|
||||
#
|
||||
# Vulture (see the `dead_code` nox session) cannot see some usages: symbols
|
||||
# referenced only through `__all__`, dynamic attribute access, framework entry
|
||||
# points, or `TYPE_CHECKING`-only re-exports look "unused" to it. List such
|
||||
# confirmed-intentional names here so they stop showing up in the report.
|
||||
#
|
||||
# The idiom is a *bare reference* to the name (a load), one per line — that is
|
||||
# what marks it "used". Keep a comment explaining why each entry is a false
|
||||
# positive, and prune entries when the underlying symbol is deleted.
|
||||
|
||||
# Re-exported for downstream typing via `__all__` in
|
||||
# composio/core/models/custom_tool.py (TYPE_CHECKING-only imports, so vulture
|
||||
# does not connect the __all__ string to the import binding).
|
||||
SessionAttachResponseExperimental # noqa: B018, F821
|
||||
SessionCreateResponseExperimental # noqa: B018, F821
|
||||
SessionRetrieveResponseExperimental # noqa: B018, F821
|
||||
@@ -0,0 +1,23 @@
|
||||
# Development Docs
|
||||
|
||||
## Setup
|
||||
|
||||
Install [pipenv](https://pipenv.pypa.io), then run `make env` to create a new environment.
|
||||
|
||||
**NOTE**: You can use `make env` to create a new environment and clean your environment up every time.
|
||||
|
||||
## Plugins
|
||||
|
||||
The providers are in the `plugins/` folder, and these are namespaced under `composio_PROVIDER`.
|
||||
|
||||
|
||||
## Testing
|
||||
|
||||
This project uses `tox` to run tests since the optional plugins could conflict with each other.
|
||||
You do not need to install this separately if you've installed the dev dependencies by running
|
||||
`make env`. Here are the commands to test the different providers:
|
||||
|
||||
1. `tox -r -e core` - Tests the core package
|
||||
2. `tox -r -e openai` - Tests the openai plugin
|
||||
3. `tox -r -e langchain` - Tests the langchain plugin
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Creating python package release
|
||||
|
||||
1. Decide which packages you want to publish
|
||||
2. Run `python scripts/bump.py`
|
||||
3. This will prompt you with the different packages and the next version, select next version or skip for given package
|
||||
4. Create a release PR
|
||||
5. Merge and publish a github release
|
||||
|
||||
**NOTES**
|
||||
|
||||
* Since the development is active on `next` branch, only select this branch when creating github release
|
||||
* While the development is active, only create release-candidate by selecting `pre` when prompted for next version in `bump.py`
|
||||
@@ -0,0 +1,844 @@
|
||||
# Tool Router
|
||||
|
||||
The Tool Router provides a powerful way to create isolated MCP (Model Context Protocol) sessions for users with scoped access to toolkits and tools. It enables dynamic configuration of which tools are available within a session and manages authentication for multiple toolkits.
|
||||
|
||||
## Overview
|
||||
|
||||
Tool Router allows you to:
|
||||
|
||||
- Create isolated sessions with specific toolkit configurations
|
||||
- Manage authentication flows for users across multiple toolkits
|
||||
- Access tools via an MCP-compatible server URL
|
||||
- Query toolkit connection states
|
||||
- Integrate with multiple AI frameworks (OpenAI, Anthropic, LangChain, LlamaIndex, CrewAI)
|
||||
|
||||
> **Note:** When using MCP clients to connect to Tool Router, you don't need to pass a provider to the Composio constructor. A provider is only required when using `session.tools()` to get framework-specific tool objects.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install composio
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using MCP (Recommended)
|
||||
|
||||
When using Tool Router with MCP clients, you don't need to pass a provider:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
# Create a session for a user with access to Gmail tools
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail']
|
||||
)
|
||||
|
||||
# Use the MCP URL with any MCP-compatible client
|
||||
print(session.mcp.url)
|
||||
```
|
||||
|
||||
### Using Framework-Specific Tools
|
||||
|
||||
If you want to use `session.tools()` to get tools formatted for a specific AI framework, pass the appropriate provider:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
from composio_openai import OpenAIProvider
|
||||
|
||||
composio = Composio(provider=OpenAIProvider())
|
||||
|
||||
# Create a session for a user with access to Gmail tools
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail']
|
||||
)
|
||||
|
||||
# Get the tools formatted for OpenAI
|
||||
tools = session.tools()
|
||||
```
|
||||
|
||||
## Creating Sessions
|
||||
|
||||
### Basic Session Creation
|
||||
|
||||
Use `composio.tool_router.create()` to create a new Tool Router session:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
# Create a session with Gmail access
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail']
|
||||
)
|
||||
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print(f"MCP URL: {session.mcp.url}")
|
||||
```
|
||||
|
||||
### Using an Existing Session
|
||||
|
||||
If you have an existing session ID, you can retrieve it using `composio.tool_router.use()`:
|
||||
|
||||
```python
|
||||
session = composio.tool_router.use('existing_session_id')
|
||||
|
||||
# Access session properties
|
||||
print(session.session_id)
|
||||
print(session.mcp.url)
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The session creation accepts the following configuration options:
|
||||
|
||||
### `toolkits`
|
||||
|
||||
Specify which toolkits to enable or disable in the session.
|
||||
|
||||
```python
|
||||
# Simple list of toolkit slugs to enable
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail', 'slack', 'github']
|
||||
)
|
||||
|
||||
# Explicit enabled configuration
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits={'enable': ['gmail', 'slack']}
|
||||
)
|
||||
|
||||
# Disable specific toolkits (enable all others)
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits={'disable': ['calendar']}
|
||||
)
|
||||
```
|
||||
|
||||
### `tools`
|
||||
|
||||
Fine-grained control over which tools are available within toolkits. This is a dictionary where keys are toolkit slugs and values specify tool configuration for that toolkit.
|
||||
|
||||
```python
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail', 'github', 'slack'],
|
||||
tools={
|
||||
# List shorthand - enables only these tools
|
||||
'gmail': ['GMAIL_FETCH_EMAILS', 'GMAIL_SEND_EMAIL'],
|
||||
|
||||
# Explicit enable configuration
|
||||
'github': {'enable': ['GITHUB_CREATE_ISSUE', 'GITHUB_LIST_ISSUES']},
|
||||
|
||||
# Explicit disable configuration
|
||||
'slack': {'disable': ['SLACK_DELETE_MESSAGE']},
|
||||
|
||||
# Filter by MCP tags (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
|
||||
'linear': {'tags': ['readOnlyHint', 'idempotentHint']}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### `tags`
|
||||
|
||||
Global MCP tags to filter tools by across all toolkits. Only tools matching these tags will be available. Toolkit-level tags (specified in `tools`) override this global setting.
|
||||
|
||||
```python
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail', 'github', 'slack'],
|
||||
tags=['readOnlyHint', 'idempotentHint'] # Only show read-only and idempotent tools
|
||||
)
|
||||
```
|
||||
|
||||
Available tag values:
|
||||
- `'readOnlyHint'`: Tools that only read data
|
||||
- `'destructiveHint'`: Tools that modify or delete data
|
||||
- `'idempotentHint'`: Tools that can be safely retried
|
||||
- `'openWorldHint'`: Tools that operate in an open world context
|
||||
|
||||
### `auth_configs`
|
||||
|
||||
Map toolkits to specific authentication configurations:
|
||||
|
||||
```python
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail', 'github'],
|
||||
auth_configs={
|
||||
'gmail': 'ac_gmail_work',
|
||||
'github': 'ac_github_personal'
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### `connected_accounts`
|
||||
|
||||
Map toolkits to specific connected account IDs:
|
||||
|
||||
```python
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail'],
|
||||
connected_accounts={
|
||||
'gmail': 'ca_abc123'
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### `manage_connections`
|
||||
|
||||
Control how connections are managed within the session:
|
||||
|
||||
```python
|
||||
# Boolean: enable/disable automatic connection management
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail'],
|
||||
manage_connections=True # default
|
||||
)
|
||||
|
||||
# Dict: fine-grained control
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail'],
|
||||
manage_connections={
|
||||
'enable': True, # Whether to use tools to manage connections
|
||||
'callback_url': 'https://your-app.com/auth/callback', # Optional OAuth callback URL
|
||||
'wait_for_connections': True # NEW in v0.10.5: Wait for connections to complete
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### Wait for Connections
|
||||
|
||||
The `wait_for_connections` property (new in v0.10.5) allows the tool router session to wait for users to complete authentication before proceeding to the next step. When set to `True`, the session will block execution until all required connections are established.
|
||||
|
||||
```python
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail', 'slack'],
|
||||
manage_connections={
|
||||
'enable': True,
|
||||
'callback_url': 'https://your-app.com/auth/callback',
|
||||
'wait_for_connections': True # Session waits for connections to complete
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### `workbench`
|
||||
|
||||
Configure workbench behavior:
|
||||
|
||||
```python
|
||||
# Disable workbench entirely — no code execution tools in the session
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail'],
|
||||
workbench={
|
||||
'enable': False
|
||||
}
|
||||
)
|
||||
|
||||
# Fine-tune workbench settings
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail'],
|
||||
workbench={
|
||||
'enable': True, # default
|
||||
'enable_proxy_execution': False, # Whether to allow proxy execute calls in workbench
|
||||
'auto_offload_threshold': 300 # Character threshold for auto-offloading large responses to the workbench
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enable` | `bool` | `True` | Enables/disables the workbench entirely. When `False`, `COMPOSIO_REMOTE_WORKBENCH` and `COMPOSIO_REMOTE_BASH_TOOL` are excluded from the session. |
|
||||
| `enable_proxy_execution` | `bool` | `True` | Controls proxy API execution in the workbench. |
|
||||
| `auto_offload_threshold` | `int` | auto | Character threshold for auto-offloading large responses to the workbench. |
|
||||
|
||||
## Session Properties and Methods
|
||||
|
||||
A Tool Router session provides the following properties and methods:
|
||||
|
||||
### `session_id`
|
||||
|
||||
The unique identifier for this session.
|
||||
|
||||
```python
|
||||
print(session.session_id)
|
||||
```
|
||||
|
||||
### `mcp`
|
||||
|
||||
The MCP server configuration for this session, including authentication headers.
|
||||
|
||||
```python
|
||||
print(session.mcp.url) # The URL to connect to
|
||||
print(session.mcp.type) # ToolRouterMCPServerType.HTTP or ToolRouterMCPServerType.SSE
|
||||
print(session.mcp.headers) # Authentication headers (includes x-api-key)
|
||||
```
|
||||
|
||||
### `tools(modifiers=None)`
|
||||
|
||||
Retrieve the tools available in the session, formatted for your AI framework.
|
||||
|
||||
```python
|
||||
# Basic usage
|
||||
tools = session.tools()
|
||||
|
||||
# With session-specific modifiers (new in v0.10.5)
|
||||
from composio.core.models import before_execute_meta, after_execute_meta
|
||||
|
||||
@before_execute_meta
|
||||
def before_modifier(tool, toolkit, session_id, params):
|
||||
print(f"[Session: {session_id}] Executing {tool} from {toolkit}")
|
||||
# Add custom logging, validation, or parameter transformation
|
||||
return params
|
||||
|
||||
@after_execute_meta
|
||||
def after_modifier(tool, toolkit, session_id, response):
|
||||
print(f"[Session: {session_id}] Completed {tool}")
|
||||
# Transform results, add telemetry, or handle errors
|
||||
return response
|
||||
|
||||
tools = session.tools(modifiers=[before_modifier, after_modifier])
|
||||
```
|
||||
|
||||
#### Session-Specific Modifiers
|
||||
|
||||
Tool Router sessions now support enhanced modifiers (introduced in v0.10.5) that include session context:
|
||||
|
||||
- **`@before_execute_meta`**: Modify parameters before execution, with access to session ID
|
||||
- **`@after_execute_meta`**: Transform results after execution, with access to session ID
|
||||
- **`@modify_schema_meta`**: Customize tool schemas before they're sent to the AI model
|
||||
|
||||
These modifiers provide better context for session-based tool execution, allowing you to track which session is executing which tools.
|
||||
|
||||
### Meta Tools
|
||||
|
||||
Tool Router provides meta tools for managing connections and session state. You can access these directly using the `get_raw_tool_router_meta_tools` method (introduced in v0.10.5):
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
from composio.core.models import modify_schema_meta
|
||||
|
||||
composio = Composio()
|
||||
|
||||
# Define schema modifier
|
||||
@modify_schema_meta
|
||||
def schema_modifier(tool, toolkit, schema):
|
||||
# Customize meta tool schemas
|
||||
print(f"Modifying schema for {tool}")
|
||||
return schema
|
||||
|
||||
# Get raw meta tools for a session
|
||||
meta_tools = composio.tools.get_raw_tool_router_meta_tools(
|
||||
session_id='session_123',
|
||||
modifiers=[schema_modifier]
|
||||
)
|
||||
|
||||
print(f"Available meta tools: {[t.name for t in meta_tools]}")
|
||||
```
|
||||
|
||||
Meta tools allow you to:
|
||||
- Authorize new toolkit connections within a session
|
||||
- Query toolkit connection states
|
||||
- Manage session configuration
|
||||
|
||||
This method is useful when you need direct access to the underlying meta tools without creating a full session object.
|
||||
|
||||
### `authorize(toolkit, *, callback_url=None)`
|
||||
|
||||
Initiate an authorization flow for a toolkit.
|
||||
|
||||
```python
|
||||
# Start authorization for Gmail
|
||||
connection_request = session.authorize('gmail')
|
||||
|
||||
print(connection_request.redirect_url) # URL to redirect user for auth
|
||||
|
||||
# Wait for the user to complete authorization
|
||||
connected_account = connection_request.wait_for_connection()
|
||||
print(f"Connected: {connected_account}")
|
||||
|
||||
# With custom callback URL
|
||||
connection_request = session.authorize(
|
||||
'gmail',
|
||||
callback_url='https://your-app.com/auth/callback'
|
||||
)
|
||||
```
|
||||
|
||||
### `toolkits(...)`
|
||||
|
||||
Query the connection state of toolkits in the session.
|
||||
|
||||
```python
|
||||
# Get all toolkits
|
||||
result = session.toolkits()
|
||||
|
||||
for toolkit in result.items:
|
||||
print(f"{toolkit.name} ({toolkit.slug})")
|
||||
if toolkit.connection:
|
||||
print(f" Connected: {toolkit.connection.is_active}")
|
||||
if toolkit.connection.connected_account:
|
||||
print(f" Account ID: {toolkit.connection.connected_account.id}")
|
||||
print(f" Status: {toolkit.connection.connected_account.status}")
|
||||
|
||||
# With pagination
|
||||
result = session.toolkits(limit=10, next_cursor='cursor_abc')
|
||||
|
||||
# Filter by specific toolkits
|
||||
result = session.toolkits(toolkits=['gmail', 'slack'])
|
||||
|
||||
# Filter by connection status
|
||||
connected_toolkits = session.toolkits(is_connected=True)
|
||||
disconnected_toolkits = session.toolkits(is_connected=False)
|
||||
|
||||
# Combine all options
|
||||
result = session.toolkits(
|
||||
toolkits=['gmail', 'github'],
|
||||
limit=5,
|
||||
is_connected=True
|
||||
)
|
||||
```
|
||||
|
||||
## Framework Integrations
|
||||
|
||||
### OpenAI
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
from composio import Composio
|
||||
from composio_openai import OpenAIProvider
|
||||
|
||||
composio = Composio(provider=OpenAIProvider())
|
||||
client = OpenAI()
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail']
|
||||
)
|
||||
|
||||
tools = session.tools()
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "Find my last email from gmail"}],
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
print(response.choices[0].message)
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```python
|
||||
from anthropic import Anthropic
|
||||
from composio import Composio
|
||||
from composio_anthropic import AnthropicProvider
|
||||
|
||||
composio = Composio(provider=AnthropicProvider())
|
||||
client = Anthropic()
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail']
|
||||
)
|
||||
|
||||
tools = session.tools()
|
||||
|
||||
response = client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": "Find my last email from gmail"}],
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
print(response.content)
|
||||
```
|
||||
|
||||
### LangChain
|
||||
|
||||
```python
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain.agents import create_tool_calling_agent, AgentExecutor
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from composio import Composio
|
||||
from composio_langchain import LangChainProvider
|
||||
|
||||
composio = Composio(provider=LangChainProvider())
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail']
|
||||
)
|
||||
|
||||
tools = session.tools()
|
||||
llm = ChatOpenAI(model="gpt-4")
|
||||
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
("system", "You are a helpful assistant."),
|
||||
("human", "{input}"),
|
||||
("placeholder", "{agent_scratchpad}"),
|
||||
])
|
||||
|
||||
agent = create_tool_calling_agent(llm, tools, prompt)
|
||||
agent_executor = AgentExecutor(agent=agent, tools=tools)
|
||||
|
||||
result = agent_executor.invoke({"input": "Find my last email from gmail"})
|
||||
print(result)
|
||||
```
|
||||
|
||||
### Using MCP URL Directly
|
||||
|
||||
You can also use the MCP URL directly with any MCP-compatible client:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail'],
|
||||
manage_connections=True
|
||||
)
|
||||
|
||||
# Use with any MCP client
|
||||
mcp_url = session.mcp.url
|
||||
mcp_headers = session.mcp.headers # Pre-configured authentication headers
|
||||
print(f"MCP URL: {mcp_url}")
|
||||
print(f"MCP Type: {session.mcp.type}") # 'http' or 'sse'
|
||||
print(f"MCP Headers: {mcp_headers}") # {'x-api-key': 'your-api-key'}
|
||||
```
|
||||
|
||||
## Authorization Flow
|
||||
|
||||
When a user needs to connect a toolkit, use the `authorize()` method:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail']
|
||||
)
|
||||
|
||||
# Initiate authorization for Gmail
|
||||
connection_request = session.authorize('gmail')
|
||||
|
||||
# Log the redirect URL for the user
|
||||
print(f"Redirect URL: {connection_request.redirect_url}")
|
||||
|
||||
# Wait for the user to complete the authorization (with timeout)
|
||||
connected_account = connection_request.wait_for_connection(timeout=300)
|
||||
print(f"Connected Account: {connected_account}")
|
||||
```
|
||||
|
||||
You can also provide a custom callback URL:
|
||||
|
||||
```python
|
||||
connection_request = session.authorize(
|
||||
'gmail',
|
||||
callback_url='https://your-app.com/auth/callback'
|
||||
)
|
||||
```
|
||||
|
||||
## Querying Toolkit States
|
||||
|
||||
Use the `toolkits()` method to check connection states:
|
||||
|
||||
```python
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
session = composio.tool_router.create(user_id='user_123')
|
||||
|
||||
# Get all toolkits
|
||||
result = session.toolkits()
|
||||
|
||||
for toolkit in result.items:
|
||||
status = "Connected" if toolkit.connection and toolkit.connection.is_active else "Not connected"
|
||||
print(f"{toolkit.name}: {status}")
|
||||
|
||||
# Get only connected toolkits
|
||||
connected = session.toolkits(is_connected=True)
|
||||
print(f"You have {len(connected.items)} connected toolkits")
|
||||
|
||||
# Get only disconnected toolkits (need authorization)
|
||||
disconnected = session.toolkits(is_connected=False)
|
||||
for toolkit in disconnected.items:
|
||||
print(f"Please connect: {toolkit.name}")
|
||||
|
||||
# Pagination example
|
||||
result = session.toolkits(limit=10)
|
||||
all_toolkits = list(result.items)
|
||||
while result.next_cursor:
|
||||
result = session.toolkits(limit=10, next_cursor=result.next_cursor)
|
||||
all_toolkits.extend(result.items)
|
||||
```
|
||||
|
||||
The response structure:
|
||||
|
||||
```python
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'slug': 'gmail',
|
||||
'name': 'Gmail',
|
||||
'logo': 'https://...',
|
||||
'is_no_auth': False,
|
||||
'connection': {
|
||||
'is_active': True,
|
||||
'auth_config': {
|
||||
'id': 'ac_xxx',
|
||||
'mode': 'OAUTH2',
|
||||
'is_composio_managed': True
|
||||
},
|
||||
'connected_account': {
|
||||
'id': 'ca_xxx',
|
||||
'status': 'ACTIVE'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
'next_cursor': 'cursor_abc',
|
||||
'total_pages': 1
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **User Isolation**: Create separate sessions per user to ensure proper isolation of connections and tools.
|
||||
|
||||
2. **Toolkit Selection**: Only enable toolkits that are necessary for the use case to maintain security and reduce complexity.
|
||||
|
||||
3. **Connection Management**: Use `manage_connections=True` (default) for interactive applications where users can be prompted to connect accounts.
|
||||
|
||||
4. **Tag Filtering**: Use global `tags` to restrict tools to safe operations (e.g., `['readOnlyHint']`) or toolkit-specific tags in the `tools` configuration for fine-grained control.
|
||||
|
||||
5. **Session Reuse**: Store and reuse `session_id` to maintain user sessions across requests.
|
||||
|
||||
```python
|
||||
import redis
|
||||
|
||||
# Store session ID after creation
|
||||
session = composio.tool_router.create(user_id='user_123', toolkits=['gmail'])
|
||||
redis_client.set(f"session:user_123", session.session_id)
|
||||
|
||||
# Retrieve existing session
|
||||
session_id = redis_client.get(f"session:user_123")
|
||||
if session_id:
|
||||
session = composio.tool_router.use(session_id)
|
||||
```
|
||||
|
||||
## Type Reference
|
||||
|
||||
### ToolRouterSession
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ToolRouterSession:
|
||||
session_id: str
|
||||
mcp: ToolRouterMCPServerConfig
|
||||
tools: Callable[[Optional[Modifiers]], Any]
|
||||
authorize: Callable[..., ConnectionRequest]
|
||||
toolkits: Callable[..., ToolkitConnectionsDetails]
|
||||
```
|
||||
|
||||
### ToolRouterMCPServerConfig
|
||||
|
||||
```python
|
||||
class ToolRouterMCPServerType(str, Enum):
|
||||
HTTP = "http"
|
||||
SSE = "sse"
|
||||
|
||||
@dataclass
|
||||
class ToolRouterMCPServerConfig:
|
||||
type: ToolRouterMCPServerType
|
||||
url: str
|
||||
headers: Optional[Dict[str, Optional[str]]] = None # Authentication headers (includes x-api-key)
|
||||
```
|
||||
|
||||
### ToolkitConnectionState
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ToolkitConnectionState:
|
||||
slug: str
|
||||
name: str
|
||||
is_no_auth: bool
|
||||
connection: Optional[ToolkitConnection] = None
|
||||
logo: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class ToolkitConnection:
|
||||
is_active: bool
|
||||
auth_config: Optional[ToolkitConnectionAuthConfig] = None
|
||||
connected_account: Optional[ToolkitConnectedAccount] = None
|
||||
|
||||
@dataclass
|
||||
class ToolkitConnectionAuthConfig:
|
||||
id: str
|
||||
mode: str
|
||||
is_composio_managed: bool
|
||||
|
||||
@dataclass
|
||||
class ToolkitConnectedAccount:
|
||||
id: str
|
||||
status: str
|
||||
```
|
||||
|
||||
### ToolkitConnectionsDetails
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ToolkitConnectionsDetails:
|
||||
items: List[ToolkitConnectionState]
|
||||
total_pages: int
|
||||
next_cursor: Optional[str] = None
|
||||
```
|
||||
|
||||
### Configuration Types
|
||||
|
||||
```python
|
||||
from typing import List, Dict, Union, Literal
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
# Toolkits configuration
|
||||
ToolRouterToolkitsEnableConfig = TypedDict('ToolRouterToolkitsEnableConfig', {
|
||||
'enable': List[str]
|
||||
})
|
||||
|
||||
ToolRouterToolkitsDisableConfig = TypedDict('ToolRouterToolkitsDisableConfig', {
|
||||
'disable': List[str]
|
||||
})
|
||||
|
||||
toolkits: Union[
|
||||
List[str], # ['gmail', 'slack']
|
||||
ToolRouterToolkitsEnableConfig, # {'enable': ['gmail', 'slack']}
|
||||
ToolRouterToolkitsDisableConfig # {'disable': ['calendar']}
|
||||
]
|
||||
|
||||
# Tools configuration
|
||||
ToolRouterToolsEnableConfig = TypedDict('ToolRouterToolsEnableConfig', {
|
||||
'enable': List[str]
|
||||
})
|
||||
|
||||
ToolRouterToolsDisableConfig = TypedDict('ToolRouterToolsDisableConfig', {
|
||||
'disable': List[str]
|
||||
})
|
||||
|
||||
ToolRouterToolsTagsConfig = TypedDict('ToolRouterToolsTagsConfig', {
|
||||
'tags': List[Literal['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']]
|
||||
})
|
||||
|
||||
ToolRouterToolsConfig = Union[
|
||||
List[str], # ['GMAIL_SEND_EMAIL', 'GMAIL_FETCH_EMAILS']
|
||||
ToolRouterToolsEnableConfig, # {'enable': ['GMAIL_SEND_EMAIL']}
|
||||
ToolRouterToolsDisableConfig, # {'disable': ['GMAIL_DELETE_EMAIL']}
|
||||
ToolRouterToolsTagsConfig # {'tags': ['readOnlyHint']}
|
||||
]
|
||||
|
||||
tools: Dict[str, ToolRouterToolsConfig] # Key is toolkit slug, value is ToolRouterToolsConfig
|
||||
|
||||
# Tags configuration (global)
|
||||
tags: List[Literal['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']]
|
||||
|
||||
# Manage connections configuration
|
||||
ToolRouterManageConnectionsConfig = TypedDict('ToolRouterManageConnectionsConfig', {
|
||||
'enable': bool,
|
||||
'callback_url': str, # Optional
|
||||
'wait_for_connections': bool # NEW in v0.10.5: Wait for connections to complete
|
||||
})
|
||||
|
||||
manage_connections: Union[
|
||||
bool,
|
||||
ToolRouterManageConnectionsConfig # {'enable': True, 'callback_url': 'https://...', 'wait_for_connections': True}
|
||||
]
|
||||
|
||||
# Workbench configuration
|
||||
ToolRouterWorkbenchConfig = TypedDict('ToolRouterWorkbenchConfig', {
|
||||
'enable': bool, # Enable/disable workbench entirely (default: True)
|
||||
'enable_proxy_execution': bool, # Whether to allow proxy execute calls
|
||||
'auto_offload_threshold': int # Character threshold for auto-offloading execution to workbench
|
||||
})
|
||||
|
||||
workbench: ToolRouterWorkbenchConfig
|
||||
```
|
||||
|
||||
## What's New in v0.10.5
|
||||
|
||||
### 1. Wait for Connections
|
||||
|
||||
The new `wait_for_connections` property allows sessions to wait for users to complete authentication before proceeding:
|
||||
|
||||
```python
|
||||
session = composio.tool_router.create(
|
||||
user_id='user_123',
|
||||
toolkits=['gmail', 'slack'],
|
||||
manage_connections={
|
||||
'enable': True,
|
||||
'callback_url': 'https://your-app.com/callback',
|
||||
'wait_for_connections': True # NEW
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Enhanced Session Modifiers
|
||||
|
||||
Session-specific modifiers now include session context, making it easier to track and manage tool execution:
|
||||
|
||||
```python
|
||||
from composio.core.models import before_execute_meta, after_execute_meta
|
||||
|
||||
@before_execute_meta
|
||||
def before_modifier(tool, toolkit, session_id, params):
|
||||
print(f"[{session_id}] Executing {tool}")
|
||||
return params
|
||||
|
||||
@after_execute_meta
|
||||
def after_modifier(tool, toolkit, session_id, response):
|
||||
print(f"[{session_id}] Completed {tool}")
|
||||
return response
|
||||
|
||||
tools = session.tools(modifiers=[before_modifier, after_modifier])
|
||||
```
|
||||
|
||||
### 3. Direct Meta Tools Access
|
||||
|
||||
New method to fetch meta tools directly from a session:
|
||||
|
||||
```python
|
||||
from composio.core.models import modify_schema_meta
|
||||
|
||||
@modify_schema_meta
|
||||
def schema_modifier(tool, toolkit, schema):
|
||||
return schema
|
||||
|
||||
meta_tools = composio.tools.get_raw_tool_router_meta_tools(
|
||||
session_id='session_123',
|
||||
modifiers=[schema_modifier]
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Performance Improvements
|
||||
|
||||
- Optimized tool fetching with fewer API calls
|
||||
- Improved session API architecture for better reliability
|
||||
- Simplified internal tool execution paths
|
||||
|
||||
All changes in v0.10.5 are fully backward compatible with existing code.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
# List all auth configs
|
||||
auth_configs = composio.auth_configs.list()
|
||||
print(auth_configs)
|
||||
|
||||
# Use composio managed auth
|
||||
auth_config = composio.auth_configs.create(
|
||||
toolkit="github",
|
||||
options={
|
||||
"type": "use_composio_managed_auth",
|
||||
},
|
||||
)
|
||||
print(auth_config)
|
||||
|
||||
# Use custom auth
|
||||
auth_config = composio.auth_configs.create(
|
||||
toolkit="notion",
|
||||
options={
|
||||
"name": "Notion Auth",
|
||||
"type": "use_custom_auth",
|
||||
"auth_scheme": "OAUTH2",
|
||||
"credentials": {
|
||||
"client_id": "1234567890",
|
||||
"client_secret": "1234567890",
|
||||
"oauth_redirect_uri": "https://backend.composio.dev/api/v3/toolkits/auth/callback",
|
||||
},
|
||||
},
|
||||
)
|
||||
print(auth_config)
|
||||
|
||||
# When creating an auth config, you can check for required fields
|
||||
required_fields = composio.toolkits.get_auth_config_creation_fields(
|
||||
toolkit="NOTION",
|
||||
auth_scheme="OAUTH2",
|
||||
)
|
||||
print(required_fields)
|
||||
|
||||
# Retrieve a specific auth config
|
||||
auth_config_retrieved = composio.auth_configs.get(auth_config.id)
|
||||
print(auth_config_retrieved)
|
||||
|
||||
# Fetch required input fields for auth config
|
||||
toolkit = composio.toolkits.get(slug="notion")
|
||||
required_input_fields = [
|
||||
field.name for field in toolkit.auth_config_details or [] if field.mode
|
||||
]
|
||||
print(required_input_fields)
|
||||
|
||||
# Update an auth config
|
||||
auth_config_updated = composio.auth_configs.update(
|
||||
auth_config.id,
|
||||
options={
|
||||
"type": "custom",
|
||||
"credentials": {
|
||||
"client_id": "1234567890",
|
||||
"client_secret": "1234567890",
|
||||
},
|
||||
"tool_access_config": {
|
||||
"tools_for_connected_account_creation": ["github"],
|
||||
},
|
||||
},
|
||||
)
|
||||
print(auth_config_updated)
|
||||
|
||||
# Enable an auth config
|
||||
composio.auth_configs.enable(auth_config.id)
|
||||
print("Auth config enabled")
|
||||
|
||||
# Disable an auth config
|
||||
composio.auth_configs.disable(auth_config.id)
|
||||
print("Auth config disabled")
|
||||
|
||||
# Delete an auth config
|
||||
composio.auth_configs.delete(auth_config.id)
|
||||
print("Auth config deleted")
|
||||
@@ -0,0 +1,54 @@
|
||||
from composio import Composio
|
||||
from composio.types import auth_scheme
|
||||
|
||||
composio = Composio()
|
||||
|
||||
# List all connected accounts
|
||||
connected_accounts = composio.connected_accounts.list()
|
||||
print(connected_accounts)
|
||||
|
||||
# Create a new connected account (OAuth)
|
||||
connection_request = composio.connected_accounts.initiate(
|
||||
user_id="1234567890",
|
||||
auth_config_id="1234567890",
|
||||
)
|
||||
print(connection_request)
|
||||
|
||||
# Wait for the connection to be established (OAuth)
|
||||
connected_account = connection_request.wait_for_connection()
|
||||
print(connected_account)
|
||||
|
||||
# Create a new connected account (API Key)
|
||||
connection_request = composio.connected_accounts.initiate(
|
||||
user_id="1234567890",
|
||||
auth_config_id="1234567890",
|
||||
config=auth_scheme.api_key(
|
||||
options={
|
||||
"api_key": "1234567890",
|
||||
},
|
||||
),
|
||||
)
|
||||
print(connection_request)
|
||||
|
||||
# When creating a connected account, you can check for required fields
|
||||
required_fields = composio.toolkits.get_connected_account_initiation_fields(
|
||||
toolkit="NOTION",
|
||||
auth_scheme="API_KEY",
|
||||
)
|
||||
print(required_fields)
|
||||
|
||||
# Retrieve a specific connected account
|
||||
connected_account_retrieved = composio.connected_accounts.get(connected_account.id)
|
||||
print(connected_account_retrieved)
|
||||
|
||||
# Disable a connected account
|
||||
composio.connected_accounts.disable(connected_account.id)
|
||||
print("Connected account disabled")
|
||||
|
||||
# Enable a connected account
|
||||
composio.connected_accounts.enable(connected_account.id)
|
||||
print("Connected account enabled")
|
||||
|
||||
# Delete a connected account
|
||||
composio.connected_accounts.delete(connected_account.id)
|
||||
print("Connected account deleted")
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Custom Tools — local tools + proxy execute with OpenAI Agents SDK.
|
||||
|
||||
Shows how to create custom tools that run in-process alongside
|
||||
remote Composio tools. Includes all three patterns: standalone,
|
||||
extension (Gmail proxy), and custom toolkit.
|
||||
|
||||
Usage:
|
||||
COMPOSIO_API_KEY=... OPENAI_API_KEY=... python examples/custom_tools_agent_test.py
|
||||
"""
|
||||
# ruff: noqa: E302, E303
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
||||
from agents import Agent, Runner
|
||||
from pydantic import BaseModel, Field
|
||||
from composio import Composio
|
||||
from composio_openai_agents import OpenAIAgentsProvider
|
||||
|
||||
composio = Composio(
|
||||
base_url=os.environ.get("COMPOSIO_BASE_URL"),
|
||||
provider=OpenAIAgentsProvider(),
|
||||
)
|
||||
|
||||
# ── 1. Standalone tool — slug/name/description inferred from function ──
|
||||
|
||||
|
||||
class UserLookupInput(BaseModel):
|
||||
user_id: str = Field(description="User ID (e.g. user-1)")
|
||||
|
||||
|
||||
USERS = {
|
||||
"user-1": {"name": "Alice Johnson", "email": "alice@acme.com", "role": "admin"},
|
||||
"user-2": {"name": "Bob Smith", "email": "bob@acme.com", "role": "developer"},
|
||||
}
|
||||
|
||||
|
||||
@composio.experimental.tool()
|
||||
def get_user(input: UserLookupInput, ctx):
|
||||
"""Look up an internal user by ID. Returns name, email, and role."""
|
||||
user = USERS.get(input.user_id)
|
||||
if not user:
|
||||
raise ValueError(f'User "{input.user_id}" not found')
|
||||
return user
|
||||
|
||||
|
||||
# ── 2. Standalone tool with overrides ──────────────────────────────────
|
||||
|
||||
|
||||
class GreetInput(BaseModel):
|
||||
name: str = Field(description="Name to greet")
|
||||
style: str = Field(default="friendly", description="Greeting style")
|
||||
|
||||
|
||||
@composio.experimental.tool(
|
||||
slug="GREET_V2",
|
||||
name="Greeting Generator",
|
||||
description="Generate a personalized greeting message in different styles.",
|
||||
)
|
||||
def greet(input: GreetInput, ctx):
|
||||
"""This docstring is ignored because description= is set above."""
|
||||
greetings = {
|
||||
"friendly": f"Hey {input.name}! Great to see you!",
|
||||
"formal": f"Dear {input.name}, I hope this message finds you well.",
|
||||
"casual": f"Yo {input.name}, what's up?",
|
||||
}
|
||||
return {"message": greetings.get(input.style, f"Hello {input.name}!")}
|
||||
|
||||
|
||||
# ── 3. Extension tool — inherits Gmail auth via ctx.proxy_execute() ────
|
||||
|
||||
|
||||
class DraftInput(BaseModel):
|
||||
to: str = Field(description="Recipient email address")
|
||||
subject: str = Field(description="Email subject")
|
||||
body: str = Field(description="Email body (plain text)")
|
||||
|
||||
|
||||
@composio.experimental.tool(extends_toolkit="gmail")
|
||||
def create_draft(input: DraftInput, ctx):
|
||||
"""Create a real Gmail draft. Appears in the user's drafts folder."""
|
||||
raw_msg = (
|
||||
f"To: {input.to}\r\nSubject: {input.subject}\r\n"
|
||||
f"Content-Type: text/plain; charset=UTF-8\r\n\r\n{input.body}"
|
||||
)
|
||||
raw = base64.urlsafe_b64encode(raw_msg.encode()).decode().rstrip("=")
|
||||
res = ctx.proxy_execute(
|
||||
toolkit="gmail",
|
||||
endpoint="https://gmail.googleapis.com/gmail/v1/users/me/drafts",
|
||||
method="POST",
|
||||
body={"message": {"raw": raw}},
|
||||
)
|
||||
if res["status"] != 200:
|
||||
raise RuntimeError(f"Gmail API error {res['status']}")
|
||||
data = res["data"]
|
||||
return {"draft_id": data["id"], "to": input.to, "subject": input.subject}
|
||||
|
||||
|
||||
# ── 4. Custom toolkit — groups related tools under one namespace ───────
|
||||
|
||||
role_manager = composio.experimental.Toolkit(
|
||||
slug="ROLE_MANAGER",
|
||||
name="Role Manager",
|
||||
description="Manage user roles in the system",
|
||||
)
|
||||
|
||||
|
||||
class SetRoleInput(BaseModel):
|
||||
user_id: str = Field(description="User ID")
|
||||
role: str = Field(description="New role (admin, developer, viewer)")
|
||||
|
||||
|
||||
@role_manager.tool()
|
||||
def set_role(input: SetRoleInput, ctx):
|
||||
"""Set a user's role. Returns confirmation."""
|
||||
return {"user_id": input.user_id, "role": input.role, "updated": True}
|
||||
|
||||
|
||||
@role_manager.tool(name="List All Roles")
|
||||
def list_roles(input: UserLookupInput, ctx):
|
||||
"""List available roles for a user."""
|
||||
return {"roles": ["admin", "developer", "viewer"], "current": "admin"}
|
||||
|
||||
|
||||
# ── Agent ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def run_test(prompt, test_name):
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"TEST: {test_name}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
session = composio.create(
|
||||
user_id="default",
|
||||
toolkits=["gmail", "weathermap"],
|
||||
manage_connections=True,
|
||||
experimental={
|
||||
"custom_tools": [get_user, greet, create_draft],
|
||||
"custom_toolkits": [role_manager],
|
||||
},
|
||||
)
|
||||
tools = session.tools()
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions=(
|
||||
"You are a helpful assistant. Use Composio tools to execute tasks. "
|
||||
"In MULTI_EXECUTE, always pass arguments inside the arguments field."
|
||||
),
|
||||
model="gpt-4.1-mini",
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
print(f"> {prompt}\n")
|
||||
try:
|
||||
result = await Runner.run(agent, prompt, max_turns=25)
|
||||
print(f"\nAgent: {result.final_output}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"\nERROR: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
tests = [
|
||||
("Look up user-2", "Standalone (inferred slug/name)"),
|
||||
("Generate a formal greeting for Alice", "Standalone (overridden slug/name)"),
|
||||
("Set user-1 role to developer", "Toolkit tool"),
|
||||
("Look up user-1 and set their role to viewer", "Mixed local tools"),
|
||||
("What is the weather in Tokyo?", "Remote (weathermap)"),
|
||||
(
|
||||
'Draft an email to bob@acme.com with subject "Test" and body "Hello!"',
|
||||
"Gmail proxy",
|
||||
),
|
||||
(
|
||||
"Look up user-1, draft them an email saying hi, include weather in SF",
|
||||
"Mixed all",
|
||||
),
|
||||
]
|
||||
|
||||
results = []
|
||||
for prompt, name in tests:
|
||||
ok = await run_test(prompt, name)
|
||||
results.append((name, ok))
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print("RESULTS")
|
||||
print(f"{'=' * 60}")
|
||||
for name, ok in results:
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
|
||||
print(f"\n{sum(1 for _, ok in results if ok)}/{len(results)} passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Error: OPENAI_API_KEY env var required")
|
||||
sys.exit(1)
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Advanced examples demonstrating the ToolRouter with all configuration options.
|
||||
|
||||
This example shows various ways to configure tool router sessions with
|
||||
different parameters matching the TypeScript implementation.
|
||||
"""
|
||||
|
||||
from composio import Composio
|
||||
|
||||
# Initialize Composio SDK
|
||||
composio = Composio()
|
||||
|
||||
|
||||
def example_with_specific_toolkits():
|
||||
"""Create a session with specific toolkits enabled."""
|
||||
print("=== Specific Toolkits Example ===")
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id="user_toolkit", toolkits=["github", "slack", "linear"]
|
||||
)
|
||||
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print("Available toolkits: github, slack, linear")
|
||||
return session
|
||||
|
||||
|
||||
def example_with_disabled_toolkits():
|
||||
"""Create a session with specific toolkits disabled."""
|
||||
print("\n=== Disabled Toolkits Example ===")
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id="user_disabled", toolkits={"disabled": ["linear", "jira"]}
|
||||
)
|
||||
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print("Disabled toolkits: linear, jira")
|
||||
return session
|
||||
|
||||
|
||||
def example_with_connection_management_config():
|
||||
"""Create a session with connection management and callback URL."""
|
||||
print("\n=== Connection Management with Callback Example ===")
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id="user_callback",
|
||||
manage_connections={
|
||||
"enabled": True,
|
||||
"callback_uri": "https://myapp.com/oauth/callback",
|
||||
},
|
||||
)
|
||||
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print("Connection management enabled with custom callback")
|
||||
return session
|
||||
|
||||
|
||||
def example_with_auth_configs():
|
||||
"""Create a session with specific auth configs for toolkits."""
|
||||
print("\n=== Auth Configs Example ===")
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id="user_auth",
|
||||
toolkits=["github", "slack"],
|
||||
auth_configs={"github": "ac_github_123", "slack": "ac_slack_456"},
|
||||
)
|
||||
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print("Auth configs: github → ac_github_123, slack → ac_slack_456")
|
||||
return session
|
||||
|
||||
|
||||
def example_with_connected_accounts():
|
||||
"""Create a session with pre-configured connected accounts."""
|
||||
print("\n=== Connected Accounts Example ===")
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id="user_connected",
|
||||
toolkits=["github", "slack"],
|
||||
connected_accounts={"github": "ca_github_789", "slack": "ca_slack_012"},
|
||||
)
|
||||
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print("Connected accounts: github → ca_github_789, slack → ca_slack_012")
|
||||
return session
|
||||
|
||||
|
||||
def example_with_all_parameters():
|
||||
"""Create a session with all parameters configured."""
|
||||
print("\n=== All Parameters Example ===")
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id="user_complete",
|
||||
toolkits=["github", "slack", "notion"],
|
||||
manage_connections={
|
||||
"enabled": True,
|
||||
"callback_uri": "https://myapp.com/callback",
|
||||
},
|
||||
auth_configs={
|
||||
"github": "ac_github_xyz",
|
||||
"slack": "ac_slack_abc",
|
||||
"notion": "ac_notion_def",
|
||||
},
|
||||
connected_accounts={"github": "ca_github_111", "slack": "ca_slack_222"},
|
||||
)
|
||||
|
||||
print(f"✓ Session ID: {session.session_id}")
|
||||
print("✓ Toolkits: github, slack, notion")
|
||||
print("✓ Connection management: enabled with callback")
|
||||
print("✓ Auth configs: configured for 3 toolkits")
|
||||
print("✓ Connected accounts: 2 pre-configured")
|
||||
|
||||
return session
|
||||
|
||||
|
||||
def example_minimal_vs_maximal():
|
||||
"""Compare minimal and maximal configurations."""
|
||||
print("\n=== Minimal vs Maximal Example ===")
|
||||
|
||||
# Minimal - just user ID
|
||||
minimal_session = composio.tool_router.create(user_id="user_minimal")
|
||||
print(f"Minimal session: {minimal_session.session_id}")
|
||||
|
||||
# Maximal - all options
|
||||
maximal_session = composio.tool_router.create(
|
||||
user_id="user_maximal",
|
||||
toolkits=["github", "slack"],
|
||||
manage_connections={"enabled": True, "callback_uri": "https://app.com/cb"},
|
||||
auth_configs={"github": "ac_1", "slack": "ac_2"},
|
||||
connected_accounts={"github": "ca_1"},
|
||||
)
|
||||
print(f"Maximal session: {maximal_session.session_id}")
|
||||
|
||||
return minimal_session, maximal_session
|
||||
|
||||
|
||||
def example_type_safe_configuration():
|
||||
"""Demonstrate type-safe configuration using TypedDict."""
|
||||
print("\n=== Type-Safe Configuration Example ===")
|
||||
|
||||
from composio.core.models.tool_router import (
|
||||
ToolRouterManageConnectionsConfig,
|
||||
ToolRouterToolkitsDisabledConfig,
|
||||
)
|
||||
|
||||
# Type-safe toolkit config
|
||||
toolkit_config: ToolRouterToolkitsDisabledConfig = {"disabled": ["linear", "asana"]}
|
||||
|
||||
# Type-safe connection management config
|
||||
connection_config: ToolRouterManageConnectionsConfig = {
|
||||
"enabled": True,
|
||||
"callback_uri": "https://secure.app.com/oauth",
|
||||
"infer_scopes_from_tools": False,
|
||||
}
|
||||
|
||||
session = composio.tool_router.create(
|
||||
user_id="user_typesafe",
|
||||
toolkits=toolkit_config,
|
||||
manage_connections=connection_config,
|
||||
)
|
||||
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print("✓ Type-safe configuration applied")
|
||||
|
||||
return session
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Run all advanced examples."""
|
||||
print("Composio ToolRouter Advanced Examples\n")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
example_with_specific_toolkits()
|
||||
example_with_disabled_toolkits()
|
||||
example_with_connection_management_config()
|
||||
example_with_auth_configs()
|
||||
example_with_connected_accounts()
|
||||
example_with_all_parameters()
|
||||
example_minimal_vs_maximal()
|
||||
example_type_safe_configuration()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✓ All advanced examples completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error running examples: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Example demonstrating the use of ToolRouter in Composio SDK.
|
||||
|
||||
This example shows how to create a tool router session for a user,
|
||||
get provider-wrapped tools, and authorize toolkits.
|
||||
"""
|
||||
|
||||
from composio import Composio
|
||||
|
||||
# Initialize Composio SDK
|
||||
composio = Composio()
|
||||
|
||||
|
||||
# Example 1: Create a basic tool router session
|
||||
def basic_session_example():
|
||||
"""Create a basic tool router session."""
|
||||
print("=== Basic Session Example ===")
|
||||
|
||||
user_id = "user_123"
|
||||
|
||||
# Create a tool router session
|
||||
session = composio.tool_router.create(user_id=user_id)
|
||||
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print(f"MCP Server Type: {session.mcp.type}")
|
||||
print(f"MCP Server URL: {session.mcp.url}")
|
||||
|
||||
# Get tools wrapped for the provider
|
||||
tools = session.tools()
|
||||
print(
|
||||
f"Number of tools available: {len(tools) if isinstance(tools, list) else 'N/A'}"
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
|
||||
# Example 2: Create a session with connection management
|
||||
def session_with_connections_example():
|
||||
"""Create a session with connection management enabled."""
|
||||
print("\n=== Session with Connection Management ===")
|
||||
|
||||
user_id = "user_456"
|
||||
|
||||
# Create a tool router session with connection management
|
||||
session = composio.tool_router.create(user_id=user_id, manage_connections=True)
|
||||
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print("Connection management enabled")
|
||||
|
||||
# Get tools (now includes COMPOSIO_MANAGE_CONNECTIONS)
|
||||
tools = session.tools()
|
||||
print(
|
||||
f"Number of tools available: {len(tools) if isinstance(tools, list) else 'N/A'}"
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
|
||||
# Example 3: Authorize a toolkit
|
||||
def authorize_toolkit_example():
|
||||
"""Demonstrate toolkit authorization."""
|
||||
print("\n=== Authorize Toolkit Example ===")
|
||||
|
||||
user_id = "user_789"
|
||||
|
||||
# Create a session
|
||||
session = composio.tool_router.create(user_id=user_id)
|
||||
|
||||
# Authorize GitHub toolkit for the user
|
||||
try:
|
||||
connection_request = session.authorize("github")
|
||||
|
||||
print(f"Connection Request ID: {connection_request.id}")
|
||||
print(f"Connection Status: {connection_request.status}")
|
||||
|
||||
if connection_request.redirect_url:
|
||||
print(f"Redirect URL: {connection_request.redirect_url}")
|
||||
print("User should visit this URL to complete authorization")
|
||||
|
||||
return connection_request
|
||||
except Exception as e:
|
||||
print(f"Error authorizing toolkit: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Example 4: Get toolkit connection states
|
||||
def get_toolkits_example():
|
||||
"""Get toolkit connection states for a session."""
|
||||
print("\n=== Get Toolkits Example ===")
|
||||
|
||||
user_id = "user_101"
|
||||
|
||||
# Create a session
|
||||
session = composio.tool_router.create(user_id=user_id)
|
||||
|
||||
# Get toolkit connection states
|
||||
toolkits = session.toolkits()
|
||||
|
||||
print(f"Current toolkits: {toolkits}")
|
||||
|
||||
return toolkits
|
||||
|
||||
|
||||
# Example 5: Full workflow with advanced configuration
|
||||
def full_workflow_example():
|
||||
"""Complete workflow with session, tools, and authorization."""
|
||||
print("\n=== Full Workflow Example ===")
|
||||
|
||||
user_id = "user_full"
|
||||
|
||||
# 1. Create session with multiple configurations
|
||||
session = composio.tool_router.create(
|
||||
user_id=user_id,
|
||||
toolkits=["github", "slack"],
|
||||
manage_connections=True,
|
||||
auth_configs={"github": "ac_demo_123"},
|
||||
)
|
||||
|
||||
print(f"✓ Created session: {session.session_id}")
|
||||
|
||||
# 2. Get tools for the session
|
||||
tools = session.tools()
|
||||
print(f"✓ Retrieved {len(tools) if isinstance(tools, list) else 'N/A'} tools")
|
||||
|
||||
# 3. Check toolkits
|
||||
toolkits_result = session.toolkits()
|
||||
print(f"✓ Current toolkits: {len(toolkits_result.items)} toolkit(s)")
|
||||
|
||||
# 4. Authorize a toolkit if needed
|
||||
try:
|
||||
connection_request = session.authorize("slack")
|
||||
print("✓ Initiated authorization for Slack")
|
||||
|
||||
if connection_request.redirect_url:
|
||||
print(f" → Redirect URL: {connection_request.redirect_url}")
|
||||
except Exception as e:
|
||||
print(f"✗ Authorization error: {e}")
|
||||
|
||||
return session
|
||||
|
||||
|
||||
# Example 6: Using with custom modifiers
|
||||
def session_with_modifiers_example():
|
||||
"""Create a session and use tools with custom modifiers."""
|
||||
print("\n=== Session with Modifiers Example ===")
|
||||
|
||||
user_id = "user_modifiers"
|
||||
|
||||
# Create session
|
||||
session = composio.tool_router.create(user_id=user_id)
|
||||
|
||||
# Get tools with custom modifiers
|
||||
modifiers = {
|
||||
# Add any custom modifiers here
|
||||
# Example: timeout, custom headers, etc.
|
||||
}
|
||||
|
||||
tools = session.tools(modifiers=modifiers if modifiers else None)
|
||||
print(
|
||||
f"✓ Retrieved {len(tools) if isinstance(tools, list) else 'N/A'} tools with modifiers"
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Run all examples."""
|
||||
print("Composio ToolRouter Examples\n")
|
||||
print("=" * 50)
|
||||
|
||||
# Run examples
|
||||
try:
|
||||
basic_session_example()
|
||||
session_with_connections_example()
|
||||
authorize_toolkit_example()
|
||||
get_toolkits_example()
|
||||
full_workflow_example()
|
||||
session_with_modifiers_example()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("✓ All examples completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error running examples: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,26 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from composio import Composio
|
||||
|
||||
# Create a FastAPI app
|
||||
app = FastAPI()
|
||||
|
||||
# Create a Composio client
|
||||
composio = Composio()
|
||||
|
||||
|
||||
@app.get("/authorize/{toolkit}")
|
||||
def authorize_app(toolkit: str):
|
||||
# retrieve the user id from your app
|
||||
user_id = ""
|
||||
|
||||
# retrieve the auth config id from your app
|
||||
auth_config_id = ""
|
||||
|
||||
# initiate the connection request
|
||||
connection_request = composio.connected_accounts.initiate(
|
||||
user_id=user_id,
|
||||
auth_config_id=auth_config_id,
|
||||
)
|
||||
return RedirectResponse(url=connection_request.redirect_url) # type: ignore
|
||||
@@ -0,0 +1,34 @@
|
||||
import asyncio
|
||||
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
mcp_config = composio.mcp.create(
|
||||
name="langchain-slack-mcp",
|
||||
toolkits=[{"toolkit": "slack", "auth_config_id": "<auth-config-id>"}],
|
||||
)
|
||||
|
||||
mcp_server = mcp_config.generate(user_id="<user-id>")
|
||||
|
||||
client = MultiServerMCPClient(
|
||||
{
|
||||
"composio": {
|
||||
"url": mcp_server["url"],
|
||||
"transport": "streamable_http",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def langchain_mcp(message: str):
|
||||
tools = await client.get_tools()
|
||||
agent = create_react_agent("openai:gpt-4.1", tools)
|
||||
response = await agent.ainvoke({"messages": message})
|
||||
return response
|
||||
|
||||
|
||||
mcp_response = asyncio.run(langchain_mcp("Show me 20 most used slack channels"))
|
||||
@@ -0,0 +1,106 @@
|
||||
import os
|
||||
|
||||
from composio import (
|
||||
Composio,
|
||||
after_execute,
|
||||
before_execute,
|
||||
before_file_upload,
|
||||
schema_modifier,
|
||||
)
|
||||
from composio.types import Tool, ToolExecuteParams, ToolExecutionResponse
|
||||
|
||||
composio = Composio()
|
||||
|
||||
|
||||
@before_execute(tools=["HACKERNEWS_GET_USER"])
|
||||
def before_execute_modifier(
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
params: ToolExecuteParams,
|
||||
) -> ToolExecuteParams:
|
||||
# Perform modifications on the request
|
||||
print("before_execute_modifier", tool, toolkit)
|
||||
return params
|
||||
|
||||
|
||||
@before_file_upload(tools=["HACKERNEWS_GET_USER"])
|
||||
def rewrite_upload_path(path: str, tool: str, toolkit: str) -> str:
|
||||
"""Optional: same pattern as before_execute; use for per-tool file path policy."""
|
||||
return path
|
||||
|
||||
|
||||
@after_execute(tools=["HACKERNEWS_GET_USER"])
|
||||
def after_execute_modifier(
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
response: ToolExecutionResponse,
|
||||
) -> ToolExecutionResponse:
|
||||
return {
|
||||
**response,
|
||||
"data": {
|
||||
"karama": response["data"]["karama"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# execute tool
|
||||
response = composio.tools.execute(
|
||||
user_id="default",
|
||||
slug="HACKERNEWS_GET_USER",
|
||||
arguments={"username": "pg"},
|
||||
modifiers=[
|
||||
before_execute_modifier,
|
||||
rewrite_upload_path,
|
||||
after_execute_modifier,
|
||||
],
|
||||
)
|
||||
print(response)
|
||||
|
||||
|
||||
@schema_modifier(tools=["HACKERNEWS_GET_USER"])
|
||||
def modify_schema(
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
schema: Tool,
|
||||
) -> Tool:
|
||||
# Perform modifications on the schema
|
||||
print("modify_schema", tool, toolkit)
|
||||
return schema
|
||||
|
||||
|
||||
tools = composio.tools.get(
|
||||
user_id="default",
|
||||
slug="HACKERNEWS_GET_USER",
|
||||
modifiers=[modify_schema],
|
||||
)
|
||||
print(tools)
|
||||
|
||||
|
||||
@before_execute(toolkits=["NOTION"])
|
||||
def add_custom_auth(
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
params: ToolExecuteParams,
|
||||
) -> ToolExecuteParams:
|
||||
if params["custom_auth_params"] is None:
|
||||
params["custom_auth_params"] = {"parameters": []}
|
||||
|
||||
params["custom_auth_params"]["parameters"].append( # type: ignore
|
||||
{
|
||||
"name": "x-api-key",
|
||||
"value": os.getenv("NOTION_API_KEY"),
|
||||
"in": "header",
|
||||
}
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
result = composio.tools.execute(
|
||||
user_id="default",
|
||||
slug="NOTION_GET_DATABASES",
|
||||
arguments={},
|
||||
modifiers=[
|
||||
add_custom_auth,
|
||||
],
|
||||
)
|
||||
print(result)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Tool Router - Authorization Example
|
||||
|
||||
This example demonstrates how to authorize a toolkit connection
|
||||
within a Tool Router session.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from composio import Composio
|
||||
|
||||
# Initialize Composio
|
||||
# Set COMPOSIO_API_KEY environment variable or pass api_key parameter
|
||||
api_key = os.environ.get("COMPOSIO_API_KEY")
|
||||
if not api_key:
|
||||
print("Error: COMPOSIO_API_KEY environment variable not set")
|
||||
print("Please set it using: export COMPOSIO_API_KEY='your_api_key'")
|
||||
exit(1)
|
||||
|
||||
composio = Composio(api_key=api_key)
|
||||
|
||||
# Create a tool router session
|
||||
session = composio.create(
|
||||
user_id="user_123",
|
||||
toolkits=["github", "gmail"],
|
||||
)
|
||||
|
||||
print(f"Session created: {session.session_id}")
|
||||
|
||||
# Authorize a toolkit connection
|
||||
# This initiates the OAuth flow and returns a connection request
|
||||
connection_request = session.authorize("github")
|
||||
|
||||
print(f"\nConnection Request ID: {connection_request.id}")
|
||||
print(f"Status: {connection_request.status}")
|
||||
print(f"Redirect URL: {connection_request.redirect_url}")
|
||||
print("\nPlease visit the URL above to authorize the connection.")
|
||||
|
||||
# Wait for the user to complete the authorization
|
||||
# This will poll the API until the connection is active or timeout occurs
|
||||
try:
|
||||
connected_account = connection_request.wait_for_connection()
|
||||
print("\n✅ Connection successful!")
|
||||
print(f"Connected Account ID: {connected_account.id}")
|
||||
print(f"Status: {connected_account.status}")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Connection failed: {e}")
|
||||
@@ -0,0 +1,33 @@
|
||||
import asyncio
|
||||
|
||||
from claude_agent_sdk import ClaudeAgentOptions, query
|
||||
from composio_claude_agent_sdk import ClaudeAgentSDKProvider
|
||||
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio(provider=ClaudeAgentSDKProvider())
|
||||
session = composio.create(
|
||||
user_id="user_123",
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
options = ClaudeAgentOptions(
|
||||
system_prompt="You are an expert Python developer",
|
||||
permission_mode="bypassPermissions",
|
||||
mcp_servers={
|
||||
"composio": {
|
||||
"type": session.mcp.type,
|
||||
"url": session.mcp.url,
|
||||
"headers": session.mcp.headers,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async for message in query(
|
||||
prompt="Fetch my last email and summarize it.", options=options
|
||||
):
|
||||
print(message)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
from crewai import Agent, Crew, Task
|
||||
from crewai.mcp import MCPServerHTTP
|
||||
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
session = composio.create(
|
||||
user_id="user_123",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
role="Gmail agent",
|
||||
goal="helps with gmail related queries",
|
||||
backstory="You are a helpful assistant that can use the tools provided to you.",
|
||||
mcps=[
|
||||
MCPServerHTTP(
|
||||
url=session.mcp.url,
|
||||
headers=session.mcp.headers,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Define task
|
||||
task = Task(
|
||||
description=("Find the last email and summarize it."),
|
||||
expected_output="A summary of the last email including sender, subject, and key points.",
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
my_crew = Crew(agents=[agent], tasks=[task])
|
||||
result = my_crew.kickoff()
|
||||
print(result)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Tool Router direct_tools session preset with OpenAI Agents.
|
||||
|
||||
SESSION_PRESET_DIRECT_TOOLS is a shortcut for sessions where the full allowed
|
||||
tool set is known upfront. It loads all tools allowed by the filters directly
|
||||
into session.tools() and the MCP tool list.
|
||||
|
||||
Usage:
|
||||
COMPOSIO_API_KEY=... OPENAI_API_KEY=... python examples/tool_router/direct_tools_preset.py
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from agents import Agent, Runner
|
||||
from composio_openai_agents import OpenAIAgentsProvider
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from composio import Composio, SESSION_PRESET_DIRECT_TOOLS
|
||||
|
||||
|
||||
def require_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
raise RuntimeError(f"Set {name} before running this example.")
|
||||
return value
|
||||
|
||||
|
||||
composio = Composio(
|
||||
api_key=require_env("COMPOSIO_API_KEY"),
|
||||
base_url=os.environ.get("COMPOSIO_BASE_URL"),
|
||||
provider=OpenAIAgentsProvider(),
|
||||
)
|
||||
require_env("OPENAI_API_KEY")
|
||||
|
||||
|
||||
class UserNoteInput(BaseModel):
|
||||
username: str = Field(description="Hacker News username, for example pg")
|
||||
|
||||
|
||||
hn_research = composio.experimental.Toolkit(
|
||||
slug="HN_RESEARCH",
|
||||
name="Hacker News research",
|
||||
description="Internal research notes for Hacker News users.",
|
||||
)
|
||||
|
||||
|
||||
@hn_research.tool(
|
||||
slug="GET_USER_NOTE",
|
||||
name="Get Hacker News user note",
|
||||
description="Return an internal research note for a Hacker News username.",
|
||||
)
|
||||
def get_user_note(input: UserNoteInput, ctx):
|
||||
username = input.username.lower()
|
||||
return {
|
||||
"username": input.username,
|
||||
"note": (
|
||||
"Paul Graham; YC co-founder and essayist."
|
||||
if username == "pg"
|
||||
else f"No curated internal note for {input.username}."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
session = composio.create(
|
||||
user_id="direct-tools-example-user",
|
||||
session_preset=SESSION_PRESET_DIRECT_TOOLS,
|
||||
toolkits=["hackernews"],
|
||||
tools={"hackernews": {"enable": ["HACKERNEWS_GET_USER"]}},
|
||||
experimental={
|
||||
"custom_toolkits": [hn_research],
|
||||
},
|
||||
)
|
||||
|
||||
tools = session.tools()
|
||||
tool_names = [tool.name for tool in tools]
|
||||
assert "HACKERNEWS_GET_USER" in tool_names
|
||||
assert "LOCAL_HN_RESEARCH_GET_USER_NOTE" in tool_names
|
||||
assert "COMPOSIO_SEARCH_TOOLS" not in tool_names
|
||||
|
||||
print("Direct tools exposed to the agent:")
|
||||
for tool in tools:
|
||||
print(f"- {tool.name}")
|
||||
|
||||
agent = Agent(
|
||||
name="Direct Tools Demo Agent",
|
||||
instructions="Use the provided tools to perform the task.",
|
||||
model=os.environ.get("OPENAI_MODEL", "gpt-5.5"),
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
result = Runner.run_sync(
|
||||
agent,
|
||||
'Look up user "pg" on Hacker News and include any internal research note.',
|
||||
)
|
||||
print(result.final_output)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Example demonstrating the Tool Router session files API.
|
||||
|
||||
Shows how to list, upload, download, and delete files in a tool router
|
||||
session's virtual filesystem. Also demonstrates search and execute.
|
||||
|
||||
Requires COMPOSIO_API_KEY and OPENAI_API_KEY to be set.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from composio import Composio
|
||||
from composio_openai import OpenAIProvider
|
||||
|
||||
|
||||
def main():
|
||||
composio = Composio(provider=OpenAIProvider())
|
||||
|
||||
# Create a session
|
||||
print("Creating tool router session...")
|
||||
session = composio.tool_router.create(user_id="demo_files_user")
|
||||
print(f" Session ID: {session.session_id}")
|
||||
|
||||
# Upload a file (from bytes)
|
||||
print("\nUploading file (bytes)...")
|
||||
remote = session.experimental.files.upload(
|
||||
b'{"hello": "world"}',
|
||||
remote_path="test_data.json",
|
||||
mimetype="application/json",
|
||||
)
|
||||
print(f" Uploaded to: {remote.mount_relative_path}")
|
||||
|
||||
# List files
|
||||
print("\nListing files...")
|
||||
result = session.experimental.files.list(path="/")
|
||||
print(f" Items: {len(result.items)}")
|
||||
for item in result.items:
|
||||
print(f" - {item.mount_relative_path} ({item.size} bytes)")
|
||||
|
||||
# Download the file
|
||||
print("\nDownloading file...")
|
||||
downloaded = session.experimental.files.download(remote.mount_relative_path)
|
||||
content = downloaded.text()
|
||||
print(f" Content: {content[:80]}...")
|
||||
|
||||
# Upload from local file
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("Hello from local file")
|
||||
local_path = f.name
|
||||
try:
|
||||
print("\nUploading from local path...")
|
||||
remote2 = session.experimental.files.upload(local_path)
|
||||
print(f" Uploaded to: {remote2.mount_relative_path}")
|
||||
finally:
|
||||
Path(local_path).unlink(missing_ok=True)
|
||||
|
||||
# List again
|
||||
print("\nListing files (after 2nd upload)...")
|
||||
result2 = session.experimental.files.list(path="/")
|
||||
print(f" Items: {len(result2.items)}")
|
||||
for item in result2.items:
|
||||
print(f" - {item.mount_relative_path} ({item.size} bytes)")
|
||||
|
||||
# Delete
|
||||
print("\nDeleting test files...")
|
||||
session.experimental.files.delete(remote.mount_relative_path)
|
||||
session.experimental.files.delete(remote2.mount_relative_path)
|
||||
print(" Deleted.")
|
||||
|
||||
# Search for tools
|
||||
print("\nSearching for tools...")
|
||||
search_result = session.search(query="send email")
|
||||
print(f" Success: {search_result.success}, Results: {len(search_result.results)}")
|
||||
if search_result.results:
|
||||
r = search_result.results[0]
|
||||
print(
|
||||
f" First result: {r.primary_tool_slugs[:3] if r.primary_tool_slugs else []}"
|
||||
)
|
||||
|
||||
print("\nAll files API operations succeeded.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import asyncio
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
from langchain_openai.chat_models import ChatOpenAI
|
||||
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
session = composio.create(
|
||||
user_id="user_123",
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
try:
|
||||
mcp_client = MultiServerMCPClient(
|
||||
{
|
||||
"composio": {
|
||||
"transport": "streamable_http",
|
||||
"url": session.mcp.url,
|
||||
"headers": session.mcp.headers,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
tools = await mcp_client.get_tools()
|
||||
|
||||
agent = create_agent(
|
||||
tools=tools,
|
||||
model=ChatOpenAI(model="gpt-4o"),
|
||||
)
|
||||
|
||||
result = await agent.ainvoke(
|
||||
{
|
||||
"messages": [
|
||||
{"role": "user", "content": "Fetch my last email and summarize?"}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
print(result)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,33 @@
|
||||
from agents import Agent, HostedMCPTool, Runner
|
||||
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
session = composio.create(
|
||||
user_id="user_123",
|
||||
)
|
||||
|
||||
print(session.mcp)
|
||||
|
||||
composio_mcp = HostedMCPTool(
|
||||
tool_config={
|
||||
"type": "mcp",
|
||||
"server_label": "tool_router",
|
||||
"server_url": session.mcp.url,
|
||||
"require_approval": "never",
|
||||
"headers": session.mcp.headers,
|
||||
}
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="My Agent",
|
||||
instructions="You are a helpful assistant that can use the tools provided to you.",
|
||||
tools=[composio_mcp],
|
||||
)
|
||||
|
||||
result = Runner.run_sync(
|
||||
starting_agent=agent,
|
||||
input="Find my last email and summarize it.",
|
||||
)
|
||||
|
||||
print(result.final_output)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Tool Router preload with OpenAI Agents.
|
||||
|
||||
Shows direct tool exposure for:
|
||||
1. Composio tools via preload["tools"].
|
||||
2. SDK custom tools via preload=True on the custom tool or toolkit.
|
||||
3. A nested custom tool override with preload=False inside a preloaded toolkit.
|
||||
|
||||
Usage:
|
||||
COMPOSIO_API_KEY=... OPENAI_API_KEY=... python examples/tool_router/preload.py
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from agents import Agent, Runner
|
||||
from composio_openai_agents import OpenAIAgentsProvider
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from composio import Composio
|
||||
|
||||
|
||||
def require_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
raise RuntimeError(f"Set {name} before running this example.")
|
||||
return value
|
||||
|
||||
|
||||
class UserLookupInput(BaseModel):
|
||||
user_id: str = Field(description="Internal user ID, for example user-1")
|
||||
|
||||
|
||||
class TeamSearchInput(BaseModel):
|
||||
team: str = Field(description="Team name to search for")
|
||||
|
||||
|
||||
class AccountInput(BaseModel):
|
||||
user_id: str = Field(description="Internal user ID")
|
||||
|
||||
|
||||
INTERNAL_USERS = {
|
||||
"user-1": {
|
||||
"id": "user-1",
|
||||
"name": "Ada Lovelace",
|
||||
"email": "ada@example.com",
|
||||
"team": "platform",
|
||||
"plan": "enterprise",
|
||||
},
|
||||
"user-2": {
|
||||
"id": "user-2",
|
||||
"name": "Grace Hopper",
|
||||
"email": "grace@example.com",
|
||||
"team": "developer-tools",
|
||||
"plan": "startup",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
composio = Composio(
|
||||
api_key=require_env("COMPOSIO_API_KEY"),
|
||||
base_url=os.environ.get("COMPOSIO_BASE_URL"),
|
||||
provider=OpenAIAgentsProvider(),
|
||||
)
|
||||
require_env("OPENAI_API_KEY")
|
||||
|
||||
|
||||
@composio.experimental.tool(
|
||||
slug="LOOKUP_INTERNAL_USER",
|
||||
name="Lookup internal user",
|
||||
description="Look up an internal demo user profile by user ID.",
|
||||
preload=True,
|
||||
)
|
||||
def lookup_internal_user(input: UserLookupInput, ctx):
|
||||
user = INTERNAL_USERS.get(input.user_id)
|
||||
if not user:
|
||||
raise ValueError(f'User "{input.user_id}" not found')
|
||||
return user
|
||||
|
||||
|
||||
@composio.experimental.tool(
|
||||
slug="SEARCH_INTERNAL_USERS",
|
||||
name="Search internal users",
|
||||
description=(
|
||||
"Search demo internal users by team. This custom tool is search-only "
|
||||
"because preload is not enabled."
|
||||
),
|
||||
)
|
||||
def search_internal_users(input: TeamSearchInput, ctx):
|
||||
return {
|
||||
"results": [
|
||||
user for user in INTERNAL_USERS.values() if user["team"] == input.team
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
internal_admin = composio.experimental.Toolkit(
|
||||
slug="INTERNAL_ADMIN",
|
||||
name="Internal admin",
|
||||
description="Demo internal administration tools.",
|
||||
preload=True,
|
||||
)
|
||||
|
||||
|
||||
@internal_admin.tool(
|
||||
slug="GET_ACCOUNT_HEALTH",
|
||||
name="Get account health",
|
||||
description="Return internal account health details for a user.",
|
||||
)
|
||||
def get_account_health(input: AccountInput, ctx):
|
||||
return {
|
||||
"user_id": input.user_id,
|
||||
"health": "green",
|
||||
"open_incidents": 0,
|
||||
"renewal_risk": "low",
|
||||
}
|
||||
|
||||
|
||||
@internal_admin.tool(
|
||||
slug="GET_ACCOUNT_AUDIT_LOG",
|
||||
name="Get account audit log",
|
||||
description=(
|
||||
"Return internal account audit events. This overrides the toolkit "
|
||||
"preload default and remains search-only."
|
||||
),
|
||||
preload=False,
|
||||
)
|
||||
def get_account_audit_log(input: AccountInput, ctx):
|
||||
return {
|
||||
"user_id": input.user_id,
|
||||
"events": ["login", "settings_viewed"],
|
||||
}
|
||||
|
||||
|
||||
session = composio.create(
|
||||
user_id="preload-example-user",
|
||||
toolkits=["hackernews"],
|
||||
tools={"hackernews": {"enable": ["HACKERNEWS_GET_USER"]}},
|
||||
preload={"tools": ["HACKERNEWS_GET_USER"]},
|
||||
manage_connections=False,
|
||||
experimental={
|
||||
"custom_tools": [lookup_internal_user, search_internal_users],
|
||||
"custom_toolkits": [internal_admin],
|
||||
},
|
||||
)
|
||||
|
||||
tools = session.tools()
|
||||
tool_names = [tool.name for tool in tools]
|
||||
assert "HACKERNEWS_GET_USER" in tool_names
|
||||
assert "LOCAL_LOOKUP_INTERNAL_USER" in tool_names
|
||||
assert "LOCAL_INTERNAL_ADMIN_GET_ACCOUNT_HEALTH" in tool_names
|
||||
assert "LOCAL_SEARCH_INTERNAL_USERS" not in tool_names
|
||||
assert "LOCAL_INTERNAL_ADMIN_GET_ACCOUNT_AUDIT_LOG" not in tool_names
|
||||
|
||||
print("Direct tools exposed to the agent:")
|
||||
for tool in tools:
|
||||
print(f"- {tool.name}")
|
||||
|
||||
agent = Agent(
|
||||
name="Preload Demo Agent",
|
||||
instructions="Use the provided tools to perform the task.",
|
||||
model=os.environ.get("OPENAI_MODEL", "gpt-5.5"),
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
prompt = (
|
||||
'Look up Hacker News user "pg", then look up internal user user-1 and '
|
||||
"their account health. Summarize the useful facts."
|
||||
)
|
||||
result = Runner.run_sync(agent, prompt)
|
||||
print(result.final_output)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Tool Router - Session Update Example
|
||||
|
||||
Demonstrates using session.update() to modify a session's configuration
|
||||
after creation — e.g. adding toolkits, changing workbench settings, or
|
||||
updating preload config without creating a new session.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio(base_url=os.environ.get("COMPOSIO_BASE_URL"))
|
||||
|
||||
# Create a session with gmail only
|
||||
session = composio.create(
|
||||
user_id="session-update-demo",
|
||||
toolkits=["gmail"],
|
||||
manage_connections=False,
|
||||
)
|
||||
|
||||
print(f"Session created: {session.session_id}")
|
||||
print(f"Preload: {session.preload}")
|
||||
|
||||
# Update: add github toolkit, enable workbench, and preload a tool
|
||||
session.update(
|
||||
toolkits={"enable": ["gmail", "github"]},
|
||||
workbench={"enable": True, "sandbox_size": "medium"},
|
||||
preload={"tools": ["GITHUB_CREATE_AN_ISSUE"]},
|
||||
)
|
||||
|
||||
print("\nAfter update:")
|
||||
print(f"Preload: {session.preload}")
|
||||
|
||||
# Update: disable workbench entirely
|
||||
session.update(
|
||||
workbench={"enable": False},
|
||||
)
|
||||
|
||||
print("\nAfter disabling workbench: done")
|
||||
|
||||
# Update: clear manage_connections by passing None
|
||||
session.update(
|
||||
manage_connections=None,
|
||||
)
|
||||
|
||||
print("\nAfter clearing manage_connections: done")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Tool Router - MCP (Model Context Protocol) Example with OpenAI Agents
|
||||
|
||||
This example demonstrates how to use Tool Router with MCP servers and OpenAI Agents provider.
|
||||
The MCP server provides a standardized way to access tools across different platforms,
|
||||
and OpenAI Agents provider allows you to use these tools with OpenAI's agent framework.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agents import Agent, HostedMCPTool, Runner
|
||||
from composio_openai_agents import OpenAIAgentsProvider
|
||||
|
||||
from composio import Composio
|
||||
|
||||
|
||||
async def main():
|
||||
# Initialize Composio with OpenAI Agents provider
|
||||
# Set COMPOSIO_API_KEY environment variable or pass api_key parameter
|
||||
api_key = os.environ.get("COMPOSIO_API_KEY")
|
||||
if not api_key:
|
||||
print("Error: COMPOSIO_API_KEY environment variable not set")
|
||||
print("Please set it using: export COMPOSIO_API_KEY='your_api_key'")
|
||||
return
|
||||
|
||||
composio = Composio(api_key=api_key, provider=OpenAIAgentsProvider())
|
||||
|
||||
# Create a tool router session
|
||||
session = composio.create(
|
||||
user_id="user_123",
|
||||
toolkits=["github", "gmail"],
|
||||
)
|
||||
|
||||
mcpTool = HostedMCPTool(
|
||||
tool_config={
|
||||
"type": "mcp",
|
||||
"server_label": "tool_router",
|
||||
"server_url": session.mcp.url,
|
||||
"require_approval": "never",
|
||||
"headers": session.mcp.headers,
|
||||
}
|
||||
)
|
||||
|
||||
print(f"Session created: {session.session_id}")
|
||||
print(f"MCP Server URL: {session.mcp.url}")
|
||||
print(f"MCP Server Type: {session.mcp.type}")
|
||||
|
||||
# Create an agent with the tools from tool router
|
||||
agent = Agent(
|
||||
name="MCP Tool Router Agent",
|
||||
instructions=(
|
||||
"You are a helpful assistant that can use GitHub and Gmail tools "
|
||||
"through the MCP (Model Context Protocol) server. "
|
||||
"Help users with their GitHub and email tasks."
|
||||
),
|
||||
tools=[mcpTool],
|
||||
)
|
||||
|
||||
# Run the agent with a sample task
|
||||
print("\n--- Running Agent with Tool Router Tools ---")
|
||||
result = await Runner.run(
|
||||
starting_agent=agent,
|
||||
input=(
|
||||
"List my recent GitHub repositories and tell me about them. "
|
||||
"If successful, respond with a summary of what you found."
|
||||
),
|
||||
)
|
||||
print(f"\nAgent Result: {result.final_output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Tool Router - Toolkits Example
|
||||
|
||||
This example demonstrates how to retrieve available toolkits
|
||||
and their connection status in a Tool Router session.
|
||||
"""
|
||||
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
# Create a tool router session
|
||||
# When manage_connections is enabled, tools for managing connections are included
|
||||
session = composio.create(
|
||||
user_id="pg-test-37ee710c-d5be-4775-91f2-a8e06b937d9b",
|
||||
manage_connections=True,
|
||||
)
|
||||
|
||||
print(f"Session created: {session.session_id}")
|
||||
print(f"MCP Server: {session.mcp.url}")
|
||||
|
||||
# Get available toolkits for the session
|
||||
# This returns information about all toolkits available to the user
|
||||
toolkits = session.toolkits()
|
||||
|
||||
print("\nAvailable toolkits:")
|
||||
print(toolkits)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Tool Router - OpenAI Agents Example
|
||||
|
||||
This example demonstrates how to use Tool Router with OpenAI Agents framework.
|
||||
OpenAI Agents provides a powerful way to build AI agents that can use tools
|
||||
and execute complex workflows.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agents import Agent, Runner
|
||||
from composio_openai_agents import OpenAIAgentsProvider
|
||||
|
||||
from composio import Composio, after_execute, before_execute
|
||||
from composio.types import ToolExecuteParams, ToolExecutionResponse
|
||||
|
||||
|
||||
async def main():
|
||||
# Initialize Composio with OpenAI Agents provider
|
||||
composio = Composio(provider=OpenAIAgentsProvider())
|
||||
|
||||
# Create a tool router session for a specific user
|
||||
# This creates an isolated session with tools for the specified toolkits
|
||||
session = composio.create(
|
||||
user_id="user_123",
|
||||
toolkits=["gmail"],
|
||||
)
|
||||
|
||||
# Define logging modifiers to track tool calls
|
||||
# Pass empty lists to apply to all tools
|
||||
@before_execute(tools=[])
|
||||
def log_before_execute(
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
params: ToolExecuteParams,
|
||||
) -> ToolExecuteParams:
|
||||
"""Log tool execution before it runs."""
|
||||
print(f"🔧 Executing tool: {toolkit}.{tool}")
|
||||
print(f" Arguments: {params.get('arguments', {})}")
|
||||
return params
|
||||
|
||||
@after_execute(tools=[])
|
||||
def log_after_execute(
|
||||
tool: str,
|
||||
toolkit: str,
|
||||
response: ToolExecutionResponse,
|
||||
) -> ToolExecutionResponse:
|
||||
"""Log tool execution after it completes."""
|
||||
print(f"✅ Completed tool: {toolkit}.{tool}")
|
||||
if "data" in response:
|
||||
print(f" Response data: {response['data']}")
|
||||
return response
|
||||
|
||||
# Get tools wrapped for OpenAI Agents with logging modifiers
|
||||
# These tools are ready to be used with the OpenAI Agents framework
|
||||
tools = session.tools(modifiers=[log_before_execute, log_after_execute])
|
||||
|
||||
print(f"\nAvailable tools: {len(tools)}")
|
||||
|
||||
# Create an agent with the tools from the session
|
||||
agent = Agent(
|
||||
name="Gmail Assistant",
|
||||
instructions=(
|
||||
"You are a helpful assistant that helps users manage their "
|
||||
"Gmail accounts. You can check emails, "
|
||||
"send messages, and perform various actions on the Gmail platform."
|
||||
),
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
# Define the task
|
||||
task = "Fetch my last email from gmail and summarize it"
|
||||
|
||||
print(f"\nTask: {task}")
|
||||
print("\nAgent working...\n")
|
||||
|
||||
# Run the agent
|
||||
result = await Runner.run(
|
||||
starting_agent=agent,
|
||||
input=task,
|
||||
)
|
||||
|
||||
# Print the final output
|
||||
print("\n" + "=" * 50)
|
||||
print("RESULT:")
|
||||
print("=" * 50)
|
||||
print(result.final_output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
# List all toolkits
|
||||
toolkits = composio.toolkits.get()
|
||||
print(toolkits)
|
||||
|
||||
# Get a toolkit by slug
|
||||
toolkit = composio.toolkits.get(slug="github")
|
||||
print(toolkit)
|
||||
|
||||
# List all toolkits categories
|
||||
categories = composio.toolkits.list_categories()
|
||||
print(categories)
|
||||
|
||||
# Authorize a user to a toolkit
|
||||
connection_request = composio.toolkits.authorize(user_id="123", toolkit="github")
|
||||
print(connection_request)
|
||||
@@ -0,0 +1,36 @@
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
# Get all tools by toolkit
|
||||
tools = composio.tools.get(user_id="default", toolkits=["GITHUB"])
|
||||
|
||||
# Get all tools by search
|
||||
tools = composio.tools.get(user_id="default", search="user")
|
||||
|
||||
# Get all tools by toolkit and search
|
||||
tools = composio.tools.get(user_id="default", toolkits=["GITHUB"], search="star")
|
||||
|
||||
# execute tool
|
||||
response = composio.tools.execute(
|
||||
user_id="default",
|
||||
slug="HACKERNEWS_GET_USER",
|
||||
arguments={"username": "pg"},
|
||||
version="20251023_00",
|
||||
)
|
||||
print(response)
|
||||
|
||||
# execute proxy call (github)
|
||||
proxy_response = composio.tools.proxy(
|
||||
endpoint="/repos/composiohq/composio/issues/1",
|
||||
method="GET",
|
||||
connected_account_id="ac_1234", # use connected account for github
|
||||
parameters=[
|
||||
{
|
||||
"name": "Accept",
|
||||
"value": "application/vnd.github.v3+json",
|
||||
"type": "header",
|
||||
},
|
||||
],
|
||||
)
|
||||
print(proxy_response)
|
||||
@@ -0,0 +1,95 @@
|
||||
from composio import Composio
|
||||
|
||||
composio = Composio()
|
||||
|
||||
|
||||
# List all triggers
|
||||
triggers = composio.triggers.list()
|
||||
print(triggers)
|
||||
|
||||
# List all active triggers
|
||||
active_triggers = composio.triggers.list_active()
|
||||
print(active_triggers)
|
||||
|
||||
# List all triggers enums
|
||||
trigger_enums = composio.triggers.list_enum()
|
||||
print(trigger_enums)
|
||||
|
||||
# Get a trigger by id
|
||||
trigger = composio.triggers.get_type(slug="GITHUB_COMMIT_EVENT")
|
||||
print(trigger)
|
||||
|
||||
# Create a trigger instance
|
||||
instance = composio.triggers.create(
|
||||
slug="GITHUB_COMMIT_EVENT",
|
||||
connected_account_id="123",
|
||||
trigger_config={
|
||||
"repo": "composio",
|
||||
"owner": "composiohq",
|
||||
},
|
||||
)
|
||||
print(instance)
|
||||
|
||||
# Or use user ID
|
||||
instance = composio.triggers.create(
|
||||
slug="GITHUB_COMMIT_EVENT",
|
||||
user_id="user@email.com",
|
||||
trigger_config={
|
||||
"repo": "composio",
|
||||
"owner": "composiohq",
|
||||
},
|
||||
)
|
||||
print(instance)
|
||||
|
||||
# Disable a trigger instance
|
||||
disabled_instance = composio.triggers.disable(trigger_id="123")
|
||||
print(disabled_instance)
|
||||
|
||||
# Enable a trigger instance
|
||||
enabled_instance = composio.triggers.enable(trigger_id="123")
|
||||
print(enabled_instance)
|
||||
|
||||
# Delete a trigger instance
|
||||
deleted_instance = composio.triggers.delete(trigger_id="123")
|
||||
print(deleted_instance)
|
||||
|
||||
|
||||
# Verify a webhook (example in Flask)
|
||||
# @app.route('/webhook', methods=['POST'])
|
||||
# def webhook():
|
||||
# try:
|
||||
# result = composio.triggers.verify_webhook(
|
||||
# id=request.headers.get('webhook-id', ''),
|
||||
# payload=request.get_data(as_text=True),
|
||||
# signature=request.headers.get('webhook-signature', ''),
|
||||
# timestamp=request.headers.get('webhook-timestamp', ''),
|
||||
# secret=os.environ['COMPOSIO_WEBHOOK_SECRET'],
|
||||
# )
|
||||
#
|
||||
# # Result contains:
|
||||
# # - version: WebhookVersion (V1, V2, or V3)
|
||||
# # - payload: Normalized TriggerEvent
|
||||
# # - raw_payload: Original parsed payload
|
||||
# print(f"Webhook version: {result['version']}")
|
||||
# print(f"Trigger: {result['payload']['trigger_slug']}")
|
||||
# return 'OK', 200
|
||||
# except WebhookSignatureVerificationError:
|
||||
# return 'Unauthorized', 401
|
||||
|
||||
|
||||
# Subscribe to a trigger
|
||||
subscription = composio.triggers.subscribe()
|
||||
|
||||
|
||||
# Define a callback functions
|
||||
@subscription.handle(toolkit="GITHUB")
|
||||
def handle_github_event(data):
|
||||
print(data)
|
||||
|
||||
|
||||
@subscription.handle(toolkit="SLACK")
|
||||
def handle_slack_event(data):
|
||||
print(data)
|
||||
|
||||
|
||||
subscription.wait_forever()
|
||||
@@ -0,0 +1,174 @@
|
||||
import nox
|
||||
|
||||
from nox.sessions import Session
|
||||
|
||||
nox.options.default_venv_backend = "uv"
|
||||
|
||||
# Modules for both ruff and mypy
|
||||
modules_for_mypy = [
|
||||
"composio/",
|
||||
"providers/",
|
||||
"tests/",
|
||||
"scripts/",
|
||||
]
|
||||
|
||||
# Modules for ruff only (includes examples)
|
||||
modules_for_ruff = [
|
||||
"composio/",
|
||||
"providers/",
|
||||
"tests/",
|
||||
"examples/",
|
||||
"scripts/",
|
||||
]
|
||||
|
||||
# Type stubs and provider libraries installed solely so mypy can resolve
|
||||
# imports across `providers/` and `tests/`. These can't live in the locked
|
||||
# `dev` dependency group: the provider libraries (crewai, langchain,
|
||||
# llama-index, ...) would drag conflicting transitive deps into the root
|
||||
# resolution. Shared test/lint tooling (ruff, pytest, fastapi, semver,
|
||||
# langchain-openai) is sourced from the `dev` group in pyproject.toml via
|
||||
# `--group dev`, so every package is declared in exactly one place.
|
||||
type_stubs = [
|
||||
"types-requests==2.33.0.20260518",
|
||||
"types-protobuf==7.34.1.20260518",
|
||||
"anthropic==0.111.0",
|
||||
"crewai==0.134.0",
|
||||
"langchain==1.3.10",
|
||||
"langgraph==1.2.6",
|
||||
"llama-index==0.14.22",
|
||||
"openai-agents==0.17.6",
|
||||
"google-cloud-aiplatform==1.158.0",
|
||||
]
|
||||
|
||||
mypy = "mypy==2.1.0"
|
||||
|
||||
ruff = [
|
||||
"ruff",
|
||||
"--config",
|
||||
"config/ruff.toml",
|
||||
]
|
||||
|
||||
|
||||
@nox.session
|
||||
def fmt(session: Session):
|
||||
"""Format code"""
|
||||
session.install("--group", "dev")
|
||||
session.run("ruff", "check", "--select", "I", "--fix", *modules_for_ruff)
|
||||
session.run("ruff", "format", *modules_for_ruff)
|
||||
|
||||
|
||||
@nox.session
|
||||
def chk(session: Session):
|
||||
"""Check for linter and type issues"""
|
||||
session.install(".", "--group", "dev", mypy, *type_stubs)
|
||||
session.run(*ruff, "check", *modules_for_ruff)
|
||||
for module in modules_for_mypy:
|
||||
session.run("mypy", "--config-file", "config/mypy.ini", module)
|
||||
|
||||
|
||||
@nox.session
|
||||
def fix(session: Session):
|
||||
"""Fix linter issues"""
|
||||
session.install("--group", "dev")
|
||||
session.run(*ruff, "check", "--fix", *modules_for_ruff)
|
||||
|
||||
|
||||
@nox.session
|
||||
def tst(session: Session):
|
||||
"""Run the Python unit test suite."""
|
||||
session.install(".", "--group", "dev")
|
||||
session.install("./providers/langchain")
|
||||
session.install("./providers/autogen")
|
||||
test_paths = session.posargs or ["tests/"]
|
||||
session.run("pytest", *test_paths, "-v", "--tb=short")
|
||||
|
||||
|
||||
@nox.session
|
||||
def snt(session: Session):
|
||||
"""Run fast sanity tests for imports and SDK initialization."""
|
||||
session.install(".", "--group", "dev")
|
||||
test_paths = session.posargs or ["tests/test_imports.py", "tests/test_sdk.py"]
|
||||
session.run("pytest", *test_paths, "-v", "--tb=short")
|
||||
|
||||
|
||||
@nox.session
|
||||
def type_inference(session: Session):
|
||||
"""Type check provider return type inference tests.
|
||||
|
||||
This session verifies that mypy correctly infers provider-specific return
|
||||
types from `Composio.tools.get()` when using @overload signatures.
|
||||
|
||||
Unlike the `chk` session, this installs all provider packages so mypy can
|
||||
resolve the provider types and verify the type inference works correctly.
|
||||
"""
|
||||
# Install core SDK, shared dev tooling, and mypy
|
||||
session.install(".", "--group", "dev", mypy, *type_stubs)
|
||||
|
||||
# Install all provider packages for type resolution
|
||||
session.install(
|
||||
"./providers/anthropic",
|
||||
"./providers/autogen",
|
||||
"./providers/claude_agent_sdk",
|
||||
"./providers/crewai",
|
||||
"./providers/gemini",
|
||||
"./providers/google",
|
||||
"./providers/google_adk",
|
||||
"./providers/langchain",
|
||||
"./providers/langgraph",
|
||||
"./providers/llamaindex",
|
||||
"./providers/openai",
|
||||
"./providers/openai_agents",
|
||||
)
|
||||
|
||||
# Run mypy on type inference test files
|
||||
# Note: explicitly listed files are checked even if they match the exclude pattern in `mypy.ini`
|
||||
session.run(
|
||||
"mypy",
|
||||
"--config-file",
|
||||
"config/mypy.ini",
|
||||
"tests/test_type_inference.py",
|
||||
"tests/test_type_inference_anthropic.py",
|
||||
"tests/test_type_inference_autogen.py",
|
||||
"tests/test_type_inference_claude_agent_sdk.py",
|
||||
"tests/test_type_inference_crewai.py",
|
||||
"tests/test_type_inference_gemini.py",
|
||||
"tests/test_type_inference_google.py",
|
||||
"tests/test_type_inference_google_adk.py",
|
||||
"tests/test_type_inference_langchain.py",
|
||||
"tests/test_type_inference_langgraph.py",
|
||||
"tests/test_type_inference_llamaindex.py",
|
||||
"tests/test_type_inference_openai_agents.py",
|
||||
)
|
||||
|
||||
|
||||
# Modules scanned for dead code (source only, no tests/examples/scripts)
|
||||
modules_for_vulture = [
|
||||
"composio/",
|
||||
"providers/",
|
||||
]
|
||||
|
||||
|
||||
@nox.session(name="dead_code")
|
||||
def dead_code(session: Session):
|
||||
"""Report likely-dead code (unused functions, classes, variables).
|
||||
|
||||
Report-only: vulture exits 1 when it finds candidates, but we do not fail
|
||||
the session on that so it never blocks CI on false positives. Vet the
|
||||
output by hand; suppress confirmed false positives by adding the symbol to
|
||||
``config/vulture_allowlist.py``. Ruff already covers unused imports (F401)
|
||||
and unused locals (F841) in the `chk` session; vulture adds cross-module
|
||||
unused functions/classes that ruff cannot see.
|
||||
"""
|
||||
session.install("vulture>=2.14")
|
||||
session.run(
|
||||
"vulture",
|
||||
*modules_for_vulture,
|
||||
"config/vulture_allowlist.py",
|
||||
"--min-confidence",
|
||||
"80",
|
||||
"--exclude",
|
||||
"*/build/*,*/dist/*,*/.venv/*,*/.nox/*,*/__pycache__/*",
|
||||
# vulture exit codes: 0 = clean, 3 = candidates found. Report-only, so
|
||||
# neither should fail the session. (1/2 are real usage/parse errors.)
|
||||
success_codes=[0, 3],
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
# AGENTS.md
|
||||
|
||||
Python provider guidance.
|
||||
|
||||
## Scope
|
||||
|
||||
Each child directory is a Python provider package that adapts Composio to a framework or agent runtime.
|
||||
|
||||
## Skill Routing
|
||||
|
||||
Use `python-providers` for provider implementation and `python-testing` for nox/pytest verification. Use `cross-sdk-parity` when the provider mirrors a TypeScript package.
|
||||
|
||||
## Commands
|
||||
|
||||
Run from `python/`:
|
||||
|
||||
```bash
|
||||
make create-provider name=<provider-name>
|
||||
make create-provider name=<provider-name> agentic=true
|
||||
make chk
|
||||
make tst
|
||||
make type_inference
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep provider dependencies in the provider package metadata unless shared tooling needs them.
|
||||
- Preserve Python naming conventions and public import paths.
|
||||
- Add provider tests and type-inference coverage when public return types change.
|
||||
- New providers or renamed provider packages usually need explicit entries in `python/noxfile.py`'s `type_inference` install list and checked-file list.
|
||||
- Verify package metadata before release-facing changes.
|
||||
@@ -0,0 +1,73 @@
|
||||
# composio-anthropic
|
||||
|
||||
Adapts Composio tools to the Claude Messages API tool format and executes the tool calls Claude returns.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install composio composio-anthropic anthropic
|
||||
```
|
||||
|
||||
Set `COMPOSIO_API_KEY` (create one at https://dashboard.composio.dev/settings) and `ANTHROPIC_API_KEY` in your environment.
|
||||
|
||||
## Quickstart
|
||||
|
||||
`AnthropicProvider` is non-agentic: Claude returns `tool_use` blocks, `handle_tool_calls` executes them, and you send the results back as `tool_result` blocks.
|
||||
|
||||
```python
|
||||
import json
|
||||
import anthropic
|
||||
from composio import Composio
|
||||
from composio_anthropic import AnthropicProvider
|
||||
|
||||
composio = Composio(provider=AnthropicProvider())
|
||||
client = anthropic.Anthropic()
|
||||
|
||||
# Create a session for your user
|
||||
session = composio.create(user_id="user_123")
|
||||
tools = session.tools()
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Send an email to john@example.com with the subject 'Hello' and body 'Hello from Composio!'"}
|
||||
]
|
||||
|
||||
response = client.messages.create(
|
||||
model="claude-opus-4-6",
|
||||
max_tokens=4096,
|
||||
tools=tools,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# Agentic loop: keep executing tool calls until the model responds with text
|
||||
while response.stop_reason == "tool_use":
|
||||
tool_use_blocks = [block for block in response.content if block.type == "tool_use"]
|
||||
results = composio.provider.handle_tool_calls(user_id="user_123", response=response)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": tool_use_blocks[i].id, "content": json.dumps(result)}
|
||||
for i, result in enumerate(results)
|
||||
]
|
||||
})
|
||||
response = client.messages.create(
|
||||
model="claude-opus-4-6",
|
||||
max_tokens=4096,
|
||||
tools=tools,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# Print final response
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
print(block.text)
|
||||
```
|
||||
|
||||
`handle_tool_calls` extracts every `tool_use` block from the response, executes the matching Composio tools, and returns the raw results in order. Claude occasionally emits tool input as a JSON string instead of an object; the provider normalizes this before execution.
|
||||
|
||||
Building on the Claude Agent SDK instead of the Messages API? Use [`composio-claude-agent-sdk`](../claude_agent_sdk).
|
||||
|
||||
## Links
|
||||
|
||||
- Anthropic provider docs: https://docs.composio.dev/docs/providers/anthropic
|
||||
- Composio docs: https://docs.composio.dev
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Anthropic claude demo.7
|
||||
"""
|
||||
|
||||
import anthropic
|
||||
from composio_anthropic import AnthropicProvider
|
||||
|
||||
from composio import Composio
|
||||
|
||||
# Initialize tools.
|
||||
anthropic_client = anthropic.Anthropic()
|
||||
composio = Composio(provider=AnthropicProvider())
|
||||
|
||||
# Get GitHub tools that are pre-configured
|
||||
tools = composio.tools.get(user_id="default", toolkits=["GITHUB"])
|
||||
|
||||
# Get response from the LLM
|
||||
response = anthropic_client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
max_tokens=1024,
|
||||
tools=tools,
|
||||
messages=[
|
||||
{"role": "user", "content": "Star me composiohq/composio repo in github."},
|
||||
],
|
||||
)
|
||||
|
||||
# Execute the function calls.
|
||||
result = composio.provider.handle_tool_calls(user_id="default", response=response)
|
||||
print(result)
|
||||
@@ -0,0 +1,3 @@
|
||||
from composio_anthropic.provider import AnthropicProvider
|
||||
|
||||
__all__ = ("AnthropicProvider",)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user