Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 55 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,25 @@ want to use the package run:
pip install extend_ai_toolkit
```

The base package installs the framework-neutral core only. Install the adapter
extra for the framework or server runtime you want to use:

```sh
pip install "extend_ai_toolkit[langchain]"
pip install "extend_ai_toolkit[mcp]"
pip install "extend_ai_toolkit[openai]"
pip install "extend_ai_toolkit[crewai]"
```

### Requirements

- **Python**: Version 3.10 or higher
- **Extend API Key**: Sign up at [paywithextend.com](https://paywithextend.com) to obtain an API key
- **Framework-specific Requirements**:
- LangChain: `langchain` and `langchain-openai` packages
- OpenAI: `openai` package
- CrewAI: `crewai` package
- Anthropic: `anthropic` package (for Claude)
- LangChain: install `extend_ai_toolkit[langchain]`
- OpenAI Agents: install `extend_ai_toolkit[openai]`
- CrewAI: install `extend_ai_toolkit[crewai]`
- MCP: install `extend_ai_toolkit[mcp]`

## Configuration

Expand Down Expand Up @@ -89,6 +99,47 @@ The toolkit provides a comprehensive set of tools organized by functionality:
- `get_automatch_status`: Get the status of an automatch job
- `send_receipt_reminder`: Send a reminder (via email) for a transaction missing a receipt

## Core Tool Catalog And Raw Execution

The core package can be used by custom agent runtimes, workflow engines, and
service backends that want Extend tool metadata and raw structured API results
without taking a dependency on a specific AI framework.

```python
import asyncio
import os

from extend_ai_toolkit import execute_tool, list_tool_specs
from extend_ai_toolkit.shared import Configuration
from extend_ai_toolkit.shared.auth import create_extend_client


async def main():
configuration = Configuration.from_tool_str("transactions.read")
specs = list_tool_specs(configuration)

extend = create_extend_client(
api_key=os.environ["EXTEND_API_KEY"],
api_secret=os.environ["EXTEND_API_SECRET"],
)
result = await execute_tool(
extend,
"get_transactions",
{"page": 0, "per_page": 10, "status": "CLEARED"},
)

print([spec.name for spec in specs])
print(result)


asyncio.run(main())
```

`list_tool_specs` returns stable names, refs, input schemas, required scopes,
action metadata, and read/write classification. `execute_tool` validates the
input against the tool schema and returns raw structured data from the Extend
API.

## Usage Examples

### Model Context Protocol
Expand Down
27 changes: 24 additions & 3 deletions extend_ai_toolkit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
from .__version__ import __version__ as _version
from .langchain import ExtendLangChainToolkit
from .modelcontextprotocol import ExtendMCPServer, Options, validate_options
from .openai import ExtendOpenAIToolkit
from .core import ToolSpec, execute_tool, list_tool_specs

__version__ = _version

__all__ = [
"ToolSpec",
"execute_tool",
"list_tool_specs",
"ExtendLangChainToolkit",
"ExtendMCPServer",
"ExtendOpenAIToolkit",
"Options",
"validate_options",
]


def __getattr__(name):
if name == "ExtendLangChainToolkit":
from .langchain import ExtendLangChainToolkit

return ExtendLangChainToolkit
if name in {"ExtendMCPServer", "Options", "validate_options"}:
from .modelcontextprotocol import ExtendMCPServer, Options, validate_options

return {
"ExtendMCPServer": ExtendMCPServer,
"Options": Options,
"validate_options": validate_options,
}[name]
if name == "ExtendOpenAIToolkit":
from .openai import ExtendOpenAIToolkit

return ExtendOpenAIToolkit
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
189 changes: 189 additions & 0 deletions extend_ai_toolkit/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""Core Extend tool catalog and execution helpers.

This module is intentionally framework-neutral. It exposes structured tool
metadata and raw tool execution without requiring LangChain, MCP, OpenAI Agents,
or CrewAI adapter dependencies.
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Callable

from .shared import functions
from .shared.configuration import Configuration
from .shared.enums import ExtendAPITools
from .shared.tools import Tool, tools

_ACTION_ORDER = ("read", "create", "update", "delete")
_TOOL_REF_PREFIX = "extend"
_TOOL_REF_VERSION = "v1"


@dataclass(frozen=True)
class ToolSpec:
"""Framework-neutral metadata for an Extend API tool."""

name: str
ref: str
description: str
input_schema: dict[str, Any]
required_scopes: tuple[dict[str, Any], ...]
product: str
products: tuple[str, ...]
actions: tuple[str, ...]
side_effect_class: str


_RAW_FUNCTIONS: dict[ExtendAPITools, Callable[..., Any]] = {
ExtendAPITools.GET_VIRTUAL_CARDS: functions.get_virtual_cards,
ExtendAPITools.GET_VIRTUAL_CARD_DETAIL: functions.get_virtual_card_detail,
ExtendAPITools.CANCEL_VIRTUAL_CARD: functions.cancel_virtual_card,
ExtendAPITools.CLOSE_VIRTUAL_CARD: functions.close_virtual_card,
ExtendAPITools.GET_CREDIT_CARDS: functions.get_credit_cards,
ExtendAPITools.GET_CREDIT_CARD_DETAIL: functions.get_credit_card_detail,
ExtendAPITools.GET_TRANSACTIONS: functions.get_transactions,
ExtendAPITools.COUNT_TRANSACTIONS: functions.count_transactions,
ExtendAPITools.GET_TRANSACTION_DETAIL: functions.get_transaction_detail,
ExtendAPITools.UPDATE_TRANSACTION_EXPENSE_DATA: (
functions.update_transaction_expense_data
),
ExtendAPITools.GET_EXPENSE_CATEGORIES: functions.get_expense_categories,
ExtendAPITools.GET_EXPENSE_CATEGORY: functions.get_expense_category,
ExtendAPITools.GET_EXPENSE_CATEGORY_LABELS: functions.get_expense_category_labels,
ExtendAPITools.GET_EXPENSE_CATEGORY_LABEL: functions.get_expense_category_label,
ExtendAPITools.CREATE_EXPENSE_CATEGORY: functions.create_expense_category,
ExtendAPITools.CREATE_EXPENSE_CATEGORY_LABEL: (
functions.create_expense_category_label
),
ExtendAPITools.UPDATE_EXPENSE_CATEGORY: functions.update_expense_category,
ExtendAPITools.UPDATE_EXPENSE_CATEGORY_LABEL: (
functions.update_expense_category_label
),
ExtendAPITools.GET_ORGANIZATIONS: functions.get_organizations,
ExtendAPITools.GET_ORGANIZATION_MEMBERS: functions.get_organization_members,
ExtendAPITools.GET_USER_DETAILS: functions.get_user_details,
ExtendAPITools.GET_EXPENSE_POLICY: functions.get_expense_policy,
ExtendAPITools.PROPOSE_EXPENSE_CATEGORY_LABEL: (
functions.propose_transaction_expense_data
),
ExtendAPITools.CONFIRM_EXPENSE_CATEGORY_LABEL: (
functions.confirm_transaction_expense_data
),
ExtendAPITools.CREATE_RECEIPT_ATTACHMENT: functions.create_receipt_attachment,
ExtendAPITools.AUTOMATCH_RECEIPTS: functions.automatch_receipts,
ExtendAPITools.GET_AUTOMATCH_STATUS: functions.get_automatch_status,
ExtendAPITools.SEND_RECEIPT_REMINDER: functions.send_receipt_reminder,
}


def list_tool_specs(
configuration: Configuration | None = None,
*,
catalog: Sequence[Tool] | None = None,
) -> list[ToolSpec]:
"""Return framework-neutral tool metadata for the configured tools."""
selected_catalog = list(tools if catalog is None else catalog)
selected_tools = (
selected_catalog
if configuration is None
else configuration.allowed_tools(selected_catalog)
)
return [_tool_spec(tool) for tool in selected_tools]


async def execute_tool(
extend: Any,
tool_name: str,
arguments: Mapping[str, Any] | None = None,
*,
catalog: Sequence[Tool] | None = None,
) -> Any:
"""Validate and execute a raw Extend tool, returning structured API data."""
tool = _tool_by_name(tool_name, tools if catalog is None else catalog)
if tool is None:
raise ValueError(f"Unknown Extend tool: {tool_name}")
function = _RAW_FUNCTIONS.get(tool.method)
if function is None:
raise ValueError(f"Extend tool has no executor: {tool_name}")

validated_arguments = _validate_arguments(tool, arguments or {})
return await function(extend=extend, **validated_arguments)


def _tool_by_name(tool_name: str, catalog: Sequence[Tool]) -> Tool | None:
for tool in catalog:
if tool.name == tool_name or tool.method.value == tool_name:
return tool
return None


def _tool_spec(tool: Tool) -> ToolSpec:
required_scopes = tuple(_scope_payload(scope) for scope in tool.required_scope)
products = tuple(scope["product"] for scope in required_scopes)
actions = _combined_actions(required_scopes)
product = products[0] if products else ""
return ToolSpec(
name=tool.name,
ref=_tool_ref(tool),
description=tool.description,
input_schema=_schema_for(tool),
required_scopes=required_scopes,
product=product,
products=products,
actions=actions,
side_effect_class=_side_effect_class(actions),
)


def _tool_ref(tool: Tool) -> str:
product = tool.required_scope[0].type.value if tool.required_scope else "general"
return f"{_TOOL_REF_PREFIX}.{product}.{tool.name}.{_TOOL_REF_VERSION}"


def _scope_payload(scope: Any) -> dict[str, Any]:
return {
"product": scope.type.value,
"actions": _enabled_actions(scope.actions),
}


def _enabled_actions(actions: Mapping[str, Any]) -> tuple[str, ...]:
enabled = {
str(getattr(action, "value", action))
for action, is_required in actions.items()
if is_required
}
ordered = [action for action in _ACTION_ORDER if action in enabled]
ordered.extend(sorted(enabled.difference(ordered)))
return tuple(ordered)


def _combined_actions(required_scopes: Sequence[dict[str, Any]]) -> tuple[str, ...]:
enabled: set[str] = set()
for scope in required_scopes:
enabled.update(scope["actions"])
ordered = [action for action in _ACTION_ORDER if action in enabled]
ordered.extend(sorted(enabled.difference(ordered)))
return tuple(ordered)


def _side_effect_class(actions: Sequence[str]) -> str:
if any(action in {"create", "update", "delete"} for action in actions):
return "external_write"
return "read_only"


def _schema_for(tool: Tool) -> dict[str, Any]:
schema_model = tool.args_schema
if hasattr(schema_model, "model_json_schema"):
return schema_model.model_json_schema()
return schema_model.schema()


def _validate_arguments(tool: Tool, arguments: Mapping[str, Any]) -> dict[str, Any]:
validated = tool.args_schema(**dict(arguments))
if hasattr(validated, "model_dump"):
return validated.model_dump(exclude_none=True)
return validated.dict(exclude_none=True)
27 changes: 25 additions & 2 deletions extend_ai_toolkit/shared/api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from dotenv import load_dotenv
from extend import ExtendClient

from .auth import Authorization, create_client_with_auth, create_extend_client
Expand All @@ -10,7 +9,13 @@
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

load_dotenv()
try:
from dotenv import load_dotenv
except ImportError: # pragma: no cover - optional convenience dependency
load_dotenv = None

if load_dotenv is not None:
load_dotenv()


class ExtendAPI:
Expand Down Expand Up @@ -48,6 +53,9 @@ async def run(self, tool: str, *args, **kwargs) -> str:
case ExtendAPITools.GET_TRANSACTIONS.value:
output = await get_transactions(self.extend, *args, **kwargs)
return format_transactions_list(output)
case ExtendAPITools.COUNT_TRANSACTIONS.value:
output = await count_transactions(self.extend, *args, **kwargs)
return json.dumps(output)
case ExtendAPITools.GET_TRANSACTION_DETAIL.value:
output = await get_transaction_detail(self.extend, *args, **kwargs)
return format_transaction_details(output)
Expand All @@ -66,6 +74,9 @@ async def run(self, tool: str, *args, **kwargs) -> str:
case ExtendAPITools.GET_EXPENSE_CATEGORY_LABELS.value:
output = await get_expense_category_labels(self.extend, *args, **kwargs)
return json.dumps(output)
case ExtendAPITools.GET_EXPENSE_CATEGORY_LABEL.value:
output = await get_expense_category_label(self.extend, *args, **kwargs)
return json.dumps(output)
case ExtendAPITools.CREATE_EXPENSE_CATEGORY.value:
output = await create_expense_category(self.extend, *args, **kwargs)
return json.dumps(output)
Expand All @@ -81,6 +92,18 @@ async def run(self, tool: str, *args, **kwargs) -> str:
case ExtendAPITools.UPDATE_TRANSACTION_EXPENSE_DATA.value:
output = await update_transaction_expense_data(self.extend, *args, **kwargs)
return json.dumps(output)
case ExtendAPITools.GET_ORGANIZATIONS.value:
output = await get_organizations(self.extend, *args, **kwargs)
return json.dumps(output)
case ExtendAPITools.GET_ORGANIZATION_MEMBERS.value:
output = await get_organization_members(self.extend, *args, **kwargs)
return json.dumps(output)
case ExtendAPITools.GET_USER_DETAILS.value:
output = await get_user_details(self.extend, *args, **kwargs)
return json.dumps(output)
case ExtendAPITools.GET_EXPENSE_POLICY.value:
output = await get_expense_policy(self.extend, *args, **kwargs)
return json.dumps(output)
case ExtendAPITools.PROPOSE_EXPENSE_CATEGORY_LABEL.value:
output = await propose_transaction_expense_data(self.extend, *args, **kwargs)
return json.dumps(output)
Expand Down
16 changes: 13 additions & 3 deletions extend_ai_toolkit/shared/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
'expense_categories.create',
'expense_categories.update',
'receipt_attachments.read',
'receipt_attachments.create'
'receipt_attachments.create',
'organizations.read',
'users.read',
'expense_policies.read',
]


Expand Down Expand Up @@ -81,8 +84,15 @@ def from_tool_str(cls, tools: str) -> "Configuration":
validated_tools.append(validate_tool_spec(tool_spec))

for product, action_str in validated_tools:
scope = Scope(product, Actions(**{action_str: True}))
configuration.add_scope(scope)
existing_scope = next(
filter(lambda x: x.type == product, configuration.scope or []),
None
)
if existing_scope:
existing_scope.actions[action_str] = True
else:
scope = Scope(product, Actions(**{action_str: True}))
configuration.add_scope(scope)
return configuration


Expand Down
Loading