diff --git a/README.md b/README.md index 5cf6ac2..4f9df33 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/extend_ai_toolkit/__init__.py b/extend_ai_toolkit/__init__.py index 83e2ebe..caca2e3 100644 --- a/extend_ai_toolkit/__init__.py +++ b/extend_ai_toolkit/__init__.py @@ -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}") diff --git a/extend_ai_toolkit/core.py b/extend_ai_toolkit/core.py new file mode 100644 index 0000000..e1e0555 --- /dev/null +++ b/extend_ai_toolkit/core.py @@ -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) diff --git a/extend_ai_toolkit/shared/api.py b/extend_ai_toolkit/shared/api.py index 7eaef83..7446e13 100644 --- a/extend_ai_toolkit/shared/api.py +++ b/extend_ai_toolkit/shared/api.py @@ -1,4 +1,3 @@ -from dotenv import load_dotenv from extend import ExtendClient from .auth import Authorization, create_client_with_auth, create_extend_client @@ -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: @@ -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) @@ -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) @@ -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) diff --git a/extend_ai_toolkit/shared/configuration.py b/extend_ai_toolkit/shared/configuration.py index 5058ac1..b210aec 100644 --- a/extend_ai_toolkit/shared/configuration.py +++ b/extend_ai_toolkit/shared/configuration.py @@ -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', ] @@ -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 diff --git a/extend_ai_toolkit/shared/enums.py b/extend_ai_toolkit/shared/enums.py index e2ff534..4b628e0 100644 --- a/extend_ai_toolkit/shared/enums.py +++ b/extend_ai_toolkit/shared/enums.py @@ -9,15 +9,21 @@ class ExtendAPITools(Enum): GET_CREDIT_CARDS = "get_credit_cards" GET_CREDIT_CARD_DETAIL = "get_credit_card_detail" GET_TRANSACTIONS = "get_transactions" + COUNT_TRANSACTIONS = "count_transactions" GET_TRANSACTION_DETAIL = "get_transaction_detail" UPDATE_TRANSACTION_EXPENSE_DATA = "update_transaction_expense_data" GET_EXPENSE_CATEGORIES = "get_expense_categories" GET_EXPENSE_CATEGORY = "get_expense_category" GET_EXPENSE_CATEGORY_LABELS = "get_expense_category_labels" + GET_EXPENSE_CATEGORY_LABEL = "get_expense_category_label" CREATE_EXPENSE_CATEGORY = "create_expense_category" CREATE_EXPENSE_CATEGORY_LABEL = "create_expense_category_label" UPDATE_EXPENSE_CATEGORY = "update_expense_category" UPDATE_EXPENSE_CATEGORY_LABEL = "update_expense_category_label" + GET_ORGANIZATIONS = "get_organizations" + GET_ORGANIZATION_MEMBERS = "get_organization_members" + GET_USER_DETAILS = "get_user_details" + GET_EXPENSE_POLICY = "get_expense_policy" PROPOSE_EXPENSE_CATEGORY_LABEL = "propose_expense_category_label" CONFIRM_EXPENSE_CATEGORY_LABEL = "confirm_expense_category_label" CREATE_RECEIPT_ATTACHMENT = "create_receipt_attachment" @@ -43,3 +49,6 @@ class Product(Enum): TRANSACTIONS = "transactions" EXPENSE_CATEGORIES = "expense_categories" RECEIPT_ATTACHMENTS = "receipt_attachments" + ORGANIZATIONS = "organizations" + USERS = "users" + EXPENSE_POLICIES = "expense_policies" diff --git a/extend_ai_toolkit/shared/functions.py b/extend_ai_toolkit/shared/functions.py index f241cf1..ff1d4fe 100644 --- a/extend_ai_toolkit/shared/functions.py +++ b/extend_ai_toolkit/shared/functions.py @@ -14,6 +14,25 @@ pending_selections = {} +def _clean_params(params: Dict[str, Any]) -> Dict[str, Any]: + return {key: value for key, value in params.items() if value is not None} + + +def _api_client_for(extend: ExtendClient) -> Any: + api_client = getattr(extend, "_api_client", None) + if api_client is None: + raise AttributeError("Extend client does not expose a raw API client") + return api_client + + +async def _raw_get( + extend: ExtendClient, + path: str, + params: Optional[Dict[str, Any]] = None, +) -> Dict: + return await _api_client_for(extend).get(path, _clean_params(params or {})) + + # ========================= # Virtual Card Functions # ========================= @@ -150,8 +169,8 @@ def _normalize(values: Optional[Union[Sequence[str], str]]) -> Optional[List[str return normalized or None normalized_statuses = _normalize(statuses) - if normalized_statuses is None and status: - normalized_statuses = _normalize([status]) + if normalized_statuses is None: + normalized_statuses = _normalize(status) normalized_receipt_statuses = _normalize(receipt_statuses) normalized_expense_category_statuses = _normalize(expense_category_statuses) @@ -203,6 +222,58 @@ def _normalize(values: Optional[Union[Sequence[str], str]]) -> Optional[List[str raise Exception("Error getting transactions") +async def count_transactions( + extend: ExtendClient, + from_date: Optional[str] = None, + to_date: Optional[str] = None, + status: Optional[Sequence[str]] = None, + virtual_card_id: Optional[str] = None, + min_amount_cents: Optional[int] = None, + max_amount_cents: Optional[int] = None, + search_term: Optional[str] = None, + expense_category_statuses: Optional[Sequence[str]] = None, +) -> Dict: + """Count transactions matching report filters.""" + try: + count_method = getattr(extend.transactions, "count_transactions", None) + normalized_statuses = [value.upper() for value in status] if status else None + normalized_expense_statuses = ( + [value.upper()[0] + value.lower()[1:] for value in expense_category_statuses] + if expense_category_statuses + else None + ) + if count_method is not None: + return await count_method( + from_date=from_date, + to_date=to_date, + status=normalized_statuses, + virtual_card_id=virtual_card_id, + min_amount_cents=min_amount_cents, + max_amount_cents=max_amount_cents, + search_term=search_term, + expense_category_statuses=normalized_expense_statuses, + ) + + return await _raw_get( + extend, + "/reports/transactions/count", + { + "since": from_date, + "until": to_date, + "statuses": normalized_statuses, + "virtualCardId": virtual_card_id, + "minClearingBillingCents": min_amount_cents, + "maxClearingBillingCents": max_amount_cents, + "search": search_term, + "expenseCategoryStatuses": normalized_expense_statuses, + "dateType": "Transaction", + }, + ) + except Exception as e: + logger.error("Error counting transactions: %s", e) + raise Exception("Error counting transactions") + + async def get_transaction_detail(extend: ExtendClient, transaction_id: str) -> Dict: """Get a transaction detail""" try: @@ -223,17 +294,42 @@ async def get_credit_cards( page: int = 0, per_page: int = 10, status: Optional[str] = None, + type: Optional[str] = None, search_term: Optional[str] = None, sort_direction: Optional[str] = None, ) -> Dict: """Get a list of credit cards""" try: - response = await extend.credit_cards.get_credit_cards( - page=page, - per_page=per_page, - status=status.upper() if status else None, - search_term=search_term, - sort_direction=sort_direction, + credit_card_method = extend.credit_cards.get_credit_cards + parameters = inspect.signature(credit_card_method).parameters + if type is not None and "type" not in parameters: + return await _raw_get( + extend, + "/creditcards", + { + "page": page, + "count": per_page, + "statuses": status.upper() if status else None, + "types": type.upper(), + "search": search_term, + "sortDirection": sort_direction, + }, + ) + + call_kwargs = { + "page": page, + "per_page": per_page, + "status": status.upper() if status else None, + "type": type.upper() if type else None, + "search_term": search_term, + "sort_direction": sort_direction, + } + response = await credit_card_method( + **{ + key: value + for key, value in call_kwargs.items() + if key in parameters + } ) return response @@ -326,6 +422,25 @@ async def get_expense_category_labels( raise Exception("Error getting expense category labels: %s", e) +async def get_expense_category_label( + extend: ExtendClient, + category_id: str, + label_id: str, +) -> Dict: + """Get detailed information about a specific expense category label.""" + try: + label_method = getattr(extend.expense_data, "get_expense_category_label", None) + if label_method is not None: + return await label_method(category_id=category_id, label_id=label_id) + return await _raw_get( + extend, + f"/expensedata/categories/{category_id}/labels/{label_id}", + ) + except Exception as e: + logger.error("Error getting expense category label: %s", e) + raise Exception("Error getting expense category label: %s", e) + + async def create_expense_category( extend: ExtendClient, name: str, @@ -443,7 +558,7 @@ async def propose_transaction_expense_data( Dict: A confirmation request with token and expiration """ # Fetch transaction to ensure it exists - transaction = await extend.transactions.get_transaction(transaction_id) + await extend.transactions.get_transaction(transaction_id) # Generate a unique confirmation token confirmation_token = str(uuid.uuid4()) @@ -539,13 +654,99 @@ async def update_transaction_expense_data( """ try: if not user_confirmed_data_values: - raise Exception(f"User has not confirmed the expense category or label values") + raise Exception("User has not confirmed the expense category or label values") response = await extend.transactions.update_transaction_expense_data(transaction_id, data) return response except Exception as e: raise Exception(f"Error updating transaction expense data: {str(e)}") +# ========================= +# Organization And User Functions +# ========================= + +async def get_organizations(extend: ExtendClient) -> Dict: + """Get organizations available to the current user.""" + try: + organizations = getattr(extend, "organizations", None) + if organizations is not None and hasattr(organizations, "list"): + return await organizations.list() + return await _raw_get(extend, "/organizations/") + except Exception as e: + logger.error("Error getting organizations: %s", e) + raise Exception("Error getting organizations") + + +async def get_organization_members( + extend: ExtendClient, + organization_id: str, + page: Optional[int] = None, + count: Optional[int] = None, + search: Optional[str] = None, + organization_role: Optional[str] = None, + organization_roles: Optional[List[str]] = None, + show_deactivated_users: Optional[bool] = True, + render_metrics: Optional[bool] = False, +) -> Dict: + """Get members for an organization.""" + try: + organizations = getattr(extend, "organizations", None) + if organizations is not None and hasattr(organizations, "get_members"): + return await organizations.get_members( + organization_id=organization_id, + page=page, + count=count, + search=search, + organization_role=organization_role, + organization_roles=organization_roles, + show_deactivated_users=show_deactivated_users, + render_metrics=render_metrics, + ) + return await _raw_get( + extend, + f"/organizations/{organization_id}/members", + { + "page": page, + "count": count, + "search": search, + "organizationRole": organization_role, + "organizationRoles": organization_roles, + "showDeactivatedUsers": show_deactivated_users, + "renderMetrics": render_metrics, + }, + ) + except Exception as e: + logger.error("Error getting organization members: %s", e) + raise Exception("Error getting organization members") + + +async def get_user_details(extend: ExtendClient, user_id: str) -> Dict: + """Get a user's profile details.""" + try: + users = getattr(extend, "users", None) + if users is not None and hasattr(users, "get_user"): + return await users.get_user(user_id=user_id) + return await _raw_get(extend, f"/users/{user_id}") + except Exception as e: + logger.error("Error getting user details: %s", e) + raise Exception("Error getting user details") + + +async def get_expense_policy(extend: ExtendClient, organization_id: str) -> Dict: + """Get raw expense policy text for an organization.""" + try: + expense_policy = getattr(extend, "expense_policy", None) + if expense_policy is not None and hasattr(expense_policy, "get_expense_policy"): + return await expense_policy.get_expense_policy(organization_id=organization_id) + return await _raw_get( + extend, + f"/organizations/{organization_id}/expensepolicy/rawtext", + ) + except Exception as e: + logger.error("Error getting expense policy: %s", e) + raise Exception("Error getting expense policy") + + # ========================= # Receipt Attachment Functions # ========================= diff --git a/extend_ai_toolkit/shared/prompts.py b/extend_ai_toolkit/shared/prompts.py index 4aa1b0e..9f57b34 100644 --- a/extend_ai_toolkit/shared/prompts.py +++ b/extend_ai_toolkit/shared/prompts.py @@ -43,6 +43,7 @@ - page (int): The page number for the paginated list. - per_page (int): The number of credit cards per page. - status (Optional[str]): Filter credit cards by status. +- type (Optional[str]): Filter credit cards by type, such as SOURCE or DELEGATE. - search_term (Optional[str]): A search term to filter credit cards. - sort_direction (Optional[str]): Sort direction (ASC or DESC). @@ -64,7 +65,10 @@ - per_page (int): The number of transactions per page. - from_date (Optional[str]): Filter transactions starting from this date (YYYY-MM-DD). - to_date (Optional[str]): Filter transactions up to this date (YYYY-MM-DD). -- status (Optional[str]): Filter transactions by status (e.g., PENDING, CLEARED, DECLINED, etc.). +- status (Optional[List[str]]): Filter transactions by statuses (e.g., PENDING, CLEARED, DECLINED, etc.). +- receipt_statuses (Optional[List[str]]): Filter transactions by receipt statuses. +- expense_category_statuses (Optional[List[str]]): Filter by expense category status, such as Attached or Missing. +- missing_expense_categories (Optional[bool]): Filter transactions missing expense categorizations. - virtual_card_id (Optional[str]): Filter by a specific virtual card ID. - min_amount_cents (Optional[int]): Minimum transaction amount in cents. - max_amount_cents (Optional[int]): Maximum transaction amount in cents. @@ -91,6 +95,14 @@ - "numberOfPages": Total number of pages available """ +count_transactions_prompt = """ +This tool counts transactions in Extend using the same filters as transaction search. +It takes optional date, status, virtual card, amount, search, and expense category +status filters. + +The response includes the count metadata returned by the transaction report API. +""" + get_transaction_detail_prompt = """ This tool retrieves detailed information for a specific transaction in Extend. It takes the following argument: @@ -193,6 +205,15 @@ The response includes the fetched expense category labels and pagination metadata. """ +get_expense_category_label_prompt = """ +This tool retrieves detailed information for a specific expense category label in Extend. +It takes the following arguments: +- category_id (str): The ID of the parent expense category. +- label_id (str): The ID of the expense category label. + +The response includes the expense category label details. +""" + create_expense_category_prompt = """ This tool creates a new expense category in Extend. It takes the following arguments: @@ -241,6 +262,40 @@ The response includes the updated expense category label details. """ +get_organizations_prompt = """ +This tool retrieves the organizations available to the authenticated user. +It takes no arguments. + +The response includes organization IDs, names, settings, and related metadata. +""" + +get_organization_members_prompt = """ +This tool retrieves members for a specific organization. +It takes the following arguments: +- organization_id (str): The organization ID. +- page, count, search, organization_role, organization_roles: Optional filters. +- show_deactivated_users (Optional[bool]): Whether to include deactivated users. +- render_metrics (Optional[bool]): Whether to include activity metrics. + +The response includes users and pagination metadata. +""" + +get_user_details_prompt = """ +This tool retrieves detailed information for a specific user. +It takes the following argument: +- user_id (str): The user ID. + +The response includes profile, preference, organization, and status fields. +""" + +get_expense_policy_prompt = """ +This tool retrieves the raw expense policy text for an organization. +It takes the following argument: +- organization_id (str): The organization ID. + +The response includes the policy identifier, organization ID, and raw text when available. +""" + create_receipt_attachment_prompt = """ IMPORTANT: This does not require a transaction id to be passed in. Do not use one if the user does not specify a transaction id. diff --git a/extend_ai_toolkit/shared/schemas.py b/extend_ai_toolkit/shared/schemas.py index aa3109f..c143415 100644 --- a/extend_ai_toolkit/shared/schemas.py +++ b/extend_ai_toolkit/shared/schemas.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, List +from typing import Dict, List, Optional, Union from pydantic import BaseModel, Field @@ -77,10 +77,22 @@ class GetTransactions(BaseModel): None, description="End date to filter transactions (YYYY-MM-DD)." ) - status: Optional[str] = Field( + status: Optional[Union[List[str], str]] = Field( None, description="Filter transactions by status (e.g., PENDING, CLEARED, DECLINED, etc.)." ) + receipt_statuses: Optional[List[str]] = Field( + None, + description="Filter transactions by receipt statuses." + ) + expense_category_statuses: Optional[List[str]] = Field( + None, + description="Filter transactions by expense category status, such as Attached or Missing." + ) + missing_expense_categories: Optional[bool] = Field( + None, + description="Filter transactions missing expense categorizations." + ) virtual_card_id: Optional[str] = Field( None, description="Filter transactions by a specific virtual card ID." @@ -107,6 +119,42 @@ class GetTransactions(BaseModel): ) +class CountTransactions(BaseModel): + """Schema for the `count_transactions` operation.""" + from_date: Optional[str] = Field( + None, + description="Start date to filter transactions (YYYY-MM-DD)." + ) + to_date: Optional[str] = Field( + None, + description="End date to filter transactions (YYYY-MM-DD)." + ) + status: Optional[List[str]] = Field( + None, + description="Filter transactions by one or more statuses." + ) + virtual_card_id: Optional[str] = Field( + None, + description="Filter transactions by a specific virtual card ID." + ) + min_amount_cents: Optional[int] = Field( + None, + description="Minimum transaction amount in cents." + ) + max_amount_cents: Optional[int] = Field( + None, + description="Maximum transaction amount in cents." + ) + search_term: Optional[str] = Field( + None, + description="Filter transactions by search term." + ) + expense_category_statuses: Optional[List[str]] = Field( + None, + description="Filter transactions by expense category status, such as Attached or Missing." + ) + + class GetTransactionDetail(BaseModel): """Schema for the `get_transaction_detail` operation.""" transaction_id: str = Field( @@ -195,6 +243,10 @@ class GetCreditCards(BaseModel): None, description="Filter credit cards by status." ) + type: Optional[str] = Field( + None, + description="Filter credit cards by type, such as SOURCE or DELEGATE." + ) search_term: Optional[str] = Field( None, description="Search term to filter credit cards." @@ -277,6 +329,18 @@ class GetExpenseCategoryLabels(BaseModel): ) +class GetExpenseCategoryLabel(BaseModel): + """Schema for the `get_expense_category_label` operation.""" + category_id: str = Field( + ..., + description="The ID of the expense category." + ) + label_id: str = Field( + ..., + description="The ID of the expense category label." + ) + + class CreateExpenseCategory(BaseModel): """Schema for the `create_expense_category` operation.""" name: str = Field( @@ -365,6 +429,62 @@ class UpdateExpenseCategoryLabel(BaseModel): ) +class GetOrganizations(BaseModel): + """Schema for the `get_organizations` operation.""" + + +class GetOrganizationMembers(BaseModel): + """Schema for the `get_organization_members` operation.""" + organization_id: str = Field( + ..., + description="The organization ID to retrieve members for." + ) + page: Optional[int] = Field( + None, + description="Page number for pagination." + ) + count: Optional[int] = Field( + None, + description="Number of members per page." + ) + search: Optional[str] = Field( + None, + description="Search term to filter members by name or email." + ) + organization_role: Optional[str] = Field( + None, + description="Filter to a single organization role." + ) + organization_roles: Optional[List[str]] = Field( + None, + description="Filter to multiple organization roles." + ) + show_deactivated_users: Optional[bool] = Field( + True, + description="Whether to include deactivated users." + ) + render_metrics: Optional[bool] = Field( + False, + description="Whether to include activity metrics." + ) + + +class GetUserDetails(BaseModel): + """Schema for the `get_user_details` operation.""" + user_id: str = Field( + ..., + description="The user ID to retrieve." + ) + + +class GetExpensePolicy(BaseModel): + """Schema for the `get_expense_policy` operation.""" + organization_id: str = Field( + ..., + description="The organization ID whose expense policy should be retrieved." + ) + + class CreateReceiptAttachmentSchema(BaseModel): """Schema for the `create_receipt_attachment` operation.""" file_path: str = Field( diff --git a/extend_ai_toolkit/shared/tools.py b/extend_ai_toolkit/shared/tools.py index e6bfe9c..5433f67 100644 --- a/extend_ai_toolkit/shared/tools.py +++ b/extend_ai_toolkit/shared/tools.py @@ -9,17 +9,24 @@ get_virtual_card_detail_prompt, cancel_virtual_card_prompt, close_virtual_card_prompt, + count_transactions_prompt, get_transactions_prompt, get_transaction_detail_prompt, get_credit_cards_prompt, + get_expense_policy_prompt, get_expense_categories_prompt, get_expense_category_prompt, get_expense_category_labels_prompt, + get_expense_category_label_prompt, + get_organization_members_prompt, + get_organizations_prompt, create_expense_category_prompt, create_expense_category_label_prompt, update_expense_category_prompt, + update_expense_category_label_prompt, get_credit_card_detail_prompt, update_transaction_expense_data_prompt, + get_user_details_prompt, create_receipt_attachment_prompt, get_automatch_status_prompt, automatch_receipts_prompt, @@ -31,16 +38,23 @@ CancelVirtualCard, CloseVirtualCard, GetCreditCards, + CountTransactions, GetTransactions, GetTransactionDetail, + GetExpensePolicy, GetExpenseCategories, GetExpenseCategory, GetExpenseCategoryLabels, + GetExpenseCategoryLabel, + GetOrganizationMembers, + GetOrganizations, CreateExpenseCategory, CreateExpenseCategoryLabel, UpdateExpenseCategory, + UpdateExpenseCategoryLabel, GetCreditCardDetail, UpdateTransactionExpenseData, + GetUserDetails, GetAutomatchStatusSchema, AutomatchReceiptsSchema, CreateReceiptAttachmentSchema, @@ -148,6 +162,17 @@ def name(self) -> str: }) ], ), + Tool( + method=ExtendAPITools.COUNT_TRANSACTIONS, + description=count_transactions_prompt, + args_schema=CountTransactions, + required_scope=[ + Scope( + type=Product.TRANSACTIONS, + actions={"read": True} + ) + ], + ), Tool( method=ExtendAPITools.GET_TRANSACTION_DETAIL, description=get_transaction_detail_prompt, @@ -207,6 +232,17 @@ def name(self) -> str: ) ], ), + Tool( + method=ExtendAPITools.GET_EXPENSE_CATEGORY_LABEL, + description=get_expense_category_label_prompt, + args_schema=GetExpenseCategoryLabel, + required_scope=[ + Scope( + type=Product.EXPENSE_CATEGORIES, + actions={"read": True} + ) + ], + ), Tool( method=ExtendAPITools.CREATE_EXPENSE_CATEGORY, description=create_expense_category_prompt, @@ -240,6 +276,61 @@ def name(self) -> str: ) ], ), + Tool( + method=ExtendAPITools.UPDATE_EXPENSE_CATEGORY_LABEL, + description=update_expense_category_label_prompt, + args_schema=UpdateExpenseCategoryLabel, + required_scope=[ + Scope( + type=Product.EXPENSE_CATEGORIES, + actions={"read": True, "update": True} + ) + ], + ), + Tool( + method=ExtendAPITools.GET_ORGANIZATIONS, + description=get_organizations_prompt, + args_schema=GetOrganizations, + required_scope=[ + Scope( + type=Product.ORGANIZATIONS, + actions={"read": True} + ) + ], + ), + Tool( + method=ExtendAPITools.GET_ORGANIZATION_MEMBERS, + description=get_organization_members_prompt, + args_schema=GetOrganizationMembers, + required_scope=[ + Scope( + type=Product.ORGANIZATIONS, + actions={"read": True} + ) + ], + ), + Tool( + method=ExtendAPITools.GET_USER_DETAILS, + description=get_user_details_prompt, + args_schema=GetUserDetails, + required_scope=[ + Scope( + type=Product.USERS, + actions={"read": True} + ) + ], + ), + Tool( + method=ExtendAPITools.GET_EXPENSE_POLICY, + description=get_expense_policy_prompt, + args_schema=GetExpensePolicy, + required_scope=[ + Scope( + type=Product.EXPENSE_POLICIES, + actions={"read": True} + ) + ], + ), Tool( method=ExtendAPITools.CREATE_RECEIPT_ATTACHMENT, description=create_receipt_attachment_prompt, diff --git a/extend_ai_toolkit/tests/test_core.py b/extend_ai_toolkit/tests/test_core.py new file mode 100644 index 0000000..6a26daa --- /dev/null +++ b/extend_ai_toolkit/tests/test_core.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass + +import pytest + +from extend_ai_toolkit.core import execute_tool, list_tool_specs +from extend_ai_toolkit.shared import Configuration + + +def test_list_tool_specs_returns_stable_structured_metadata(): + specs = list_tool_specs( + Configuration.from_tool_str("transactions.read,transactions.update") + ) + by_name = {spec.name: spec for spec in specs} + + assert set(by_name) >= {"get_transactions", "update_transaction_expense_data"} + + transactions = by_name["get_transactions"] + assert transactions.ref == "extend.transactions.get_transactions.v1" + assert transactions.product == "transactions" + assert transactions.actions == ("read",) + assert transactions.side_effect_class == "read_only" + assert transactions.required_scopes == ( + {"product": "transactions", "actions": ("read",)}, + ) + assert "properties" in transactions.input_schema + + update = by_name["update_transaction_expense_data"] + assert update.ref == "extend.transactions.update_transaction_expense_data.v1" + assert update.product == "transactions" + assert update.actions == ("read", "update") + assert update.side_effect_class == "external_write" + assert update.required_scopes == ( + {"product": "transactions", "actions": ("read", "update")}, + ) + + +def test_list_tool_specs_allows_empty_custom_catalog(): + assert list_tool_specs(catalog=[]) == [] + + +@pytest.mark.asyncio +async def test_execute_tool_validates_arguments_and_returns_raw_structured_data(): + @dataclass + class Transactions: + async def get_transactions( + self, + page=None, + per_page=None, + from_date=None, + to_date=None, + status=None, + virtual_card_id=None, + min_amount_cents=None, + max_amount_cents=None, + search_term=None, + sort_field=None, + ): + return { + "report": {"transactions": [{"id": "txn_123"}]}, + "kwargs": { + "page": page, + "per_page": per_page, + "from_date": from_date, + "to_date": to_date, + "status": status, + "virtual_card_id": virtual_card_id, + "min_amount_cents": min_amount_cents, + "max_amount_cents": max_amount_cents, + "search_term": search_term, + "sort_field": sort_field, + }, + } + + @dataclass + class Extend: + transactions: Transactions + + result = await execute_tool( + Extend(Transactions()), + "get_transactions", + {"page": 1, "per_page": 10, "status": "cleared"}, + ) + + assert result == { + "report": {"transactions": [{"id": "txn_123"}]}, + "kwargs": { + "page": 1, + "per_page": 10, + "from_date": None, + "to_date": None, + "status": "CLEARED", + "virtual_card_id": None, + "min_amount_cents": None, + "max_amount_cents": None, + "search_term": None, + "sort_field": None, + }, + } + + +@pytest.mark.asyncio +async def test_execute_tool_rejects_unknown_tool_names(): + with pytest.raises(ValueError, match="Unknown Extend tool"): + await execute_tool(object(), "missing_tool", {}) + + +@pytest.mark.asyncio +async def test_execute_tool_allows_empty_custom_catalog(): + with pytest.raises(ValueError, match="Unknown Extend tool"): + await execute_tool(object(), "get_transactions", {}, catalog=[]) diff --git a/extend_ai_toolkit/tests/test_crewai_toolkit.py b/extend_ai_toolkit/tests/test_crewai_toolkit.py index fb8efda..87cd0d0 100644 --- a/extend_ai_toolkit/tests/test_crewai_toolkit.py +++ b/extend_ai_toolkit/tests/test_crewai_toolkit.py @@ -1,15 +1,22 @@ -import inspect -import json +# ruff: noqa: E402, I001 + import re -from unittest.mock import patch, Mock, AsyncMock +from unittest.mock import AsyncMock, Mock, patch import pytest from pydantic import BaseModel -from crewai import Agent, Task, Crew, LLM -from crewai.tools import BaseTool -from extend_ai_toolkit.crewai.toolkit import ExtendCrewAIToolkit -from extend_ai_toolkit.shared import Configuration, ExtendAPITools, Tool, ExtendAPI +crewai = pytest.importorskip("crewai") # noqa: E402 +crewai_tools = pytest.importorskip("crewai.tools") # noqa: E402 + +Agent = crewai.Agent +Task = crewai.Task +Crew = crewai.Crew +LLM = crewai.LLM +BaseTool = crewai_tools.BaseTool + +from extend_ai_toolkit.crewai.toolkit import ExtendCrewAIToolkit # noqa: E402 +from extend_ai_toolkit.shared import Configuration, ExtendAPI, ExtendAPITools, Tool # noqa: E402 # Define schema classes needed for testing @@ -237,4 +244,4 @@ def test_create_crew(toolkit): assert isinstance(crew, Crew) assert len(crew.agents) == 1 assert len(crew.tasks) == 1 - assert crew.verbose is True \ No newline at end of file + assert crew.verbose is True diff --git a/extend_ai_toolkit/tests/test_endpoint_tool_coverage.py b/extend_ai_toolkit/tests/test_endpoint_tool_coverage.py new file mode 100644 index 0000000..c781375 --- /dev/null +++ b/extend_ai_toolkit/tests/test_endpoint_tool_coverage.py @@ -0,0 +1,88 @@ +import pytest + +from extend_ai_toolkit.core import execute_tool, list_tool_specs +from extend_ai_toolkit.shared import Configuration + + +def test_catalog_exposes_current_service_endpoint_tools(): + tool_names = {spec.name for spec in list_tool_specs()} + + assert { + "count_transactions", + "get_expense_category_label", + "get_organizations", + "get_organization_members", + "get_user_details", + "get_expense_policy", + }.issubset(tool_names) + + assert { + "trigger_async_predict_expense_data_for_transactions", + "get_current_user", + "get_spend_by_expense_category", + "get_spend_by_merchant_category", + "get_spend_over_time_by_expense", + "get_spend_over_time_by_merchant", + "get_spend_vs_prior_period", + }.isdisjoint(tool_names) + + +def test_new_endpoint_families_are_available_through_scopes(): + scoped_names = { + spec.name + for spec in list_tool_specs( + Configuration.from_tool_str( + ",".join( + [ + "transactions.read", + "transactions.update", + "expense_categories.read", + "organizations.read", + "users.read", + "expense_policies.read", + ] + ) + ) + ) + } + + assert "count_transactions" in scoped_names + assert "get_expense_policy" in scoped_names + assert "get_user_details" in scoped_names + + +def test_transaction_and_card_schemas_cover_current_filter_shapes(): + specs = {spec.name: spec for spec in list_tool_specs()} + + transaction_properties = specs["get_transactions"].input_schema["properties"] + assert "receipt_statuses" in transaction_properties + assert "expense_category_statuses" in transaction_properties + assert "missing_expense_categories" in transaction_properties + + status_items = transaction_properties["status"]["anyOf"][0]["items"] + assert status_items["type"] == "string" + + credit_card_properties = specs["get_credit_cards"].input_schema["properties"] + assert "type" in credit_card_properties + + +@pytest.mark.asyncio +async def test_execute_tool_supports_raw_get_endpoints_missing_from_sdk_resources(): + class APIClient: + def __init__(self): + self.calls = [] + + async def get(self, url, params=None): + self.calls.append(("get", url, params)) + return {"user": {"id": "u_123"}} + + class Extend: + def __init__(self): + self._api_client = APIClient() + + extend = Extend() + + result = await execute_tool(extend, "get_user_details", {"user_id": "u_123"}) + + assert result == {"user": {"id": "u_123"}} + assert extend._api_client.calls == [("get", "/users/u_123", {})] diff --git a/extend_ai_toolkit/tests/test_packaging.py b/extend_ai_toolkit/tests/test_packaging.py new file mode 100644 index 0000000..c1299fa --- /dev/null +++ b/extend_ai_toolkit/tests/test_packaging.py @@ -0,0 +1,39 @@ +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility + import tomli as tomllib # type: ignore[import-not-found] + + +def _pyproject(): + path = Path(__file__).resolve().parents[2] / "pyproject.toml" + return tomllib.loads(path.read_text()) + + +def test_base_install_excludes_framework_runtime_dependencies(): + dependencies = _pyproject()["project"]["dependencies"] + + assert "paywithextend==2.0.0" in dependencies + assert not any( + dependency.startswith( + ( + "langchain", + "openai", + "openai-agents", + "mcp", + "crewai", + "starlette", + ) + ) + for dependency in dependencies + ) + + +def test_framework_dependencies_are_available_as_extras(): + extras = _pyproject()["project"]["optional-dependencies"] + + assert any(dependency.startswith("langchain") for dependency in extras["langchain"]) + assert any(dependency.startswith("mcp") for dependency in extras["mcp"]) + assert any(dependency.startswith("openai-agents") for dependency in extras["openai"]) + assert any(dependency.startswith("crewai") for dependency in extras["crewai"]) diff --git a/pyproject.toml b/pyproject.toml index 4a6f313..90620dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,17 +10,7 @@ readme = "README.md" license = { text = "MIT" } requires-python = ">=3.10" dependencies = [ - "mcp>=1.4.1", - "mypy==1.15.0", - "python-dotenv>=1.0.1", - "langchain==0.3.20", - "colorama>=0.4.4", "pydantic>=1.10.2", - "requests==2.32.3", - "build", - "starlette>=0.40.0,<0.46.0", - "openai>=1.66.3,<2.0.0", - "openai-agents==0.0.4", "paywithextend==2.0.0", ] @@ -29,7 +19,37 @@ dependencies = [ "Source Code" = "https://github.com/paywithextend/extend-ai-toolkit" [project.optional-dependencies] -dev = ["pytest>=7.0.1", "mypy>=1.11.1", "ruff>=0.6.1", "crewai>=0.108.0", "pytest-asyncio>=0.26.0"] +langchain = ["langchain==0.3.20"] +mcp = [ + "colorama>=0.4.4", + "mcp>=1.4.1", + "mypy==1.15.0", + "python-dotenv>=1.0.1", + "starlette>=0.40.0,<0.46.0", + "uvicorn>=0.27.0", +] +openai = [ + "openai>=1.66.3,<2.0.0", + "openai-agents==0.0.4", +] +crewai = ["crewai>=0.108.0"] +dev = [ + "build", + "colorama>=0.4.4", + "crewai>=0.108.0", + "langchain==0.3.20", + "mcp>=1.4.1", + "mypy>=1.15.0", + "openai>=1.66.3,<2.0.0", + "openai-agents==0.0.4", + "pytest>=7.0.1", + "pytest-asyncio>=0.26.0", + "python-dotenv>=1.0.1", + "ruff>=0.6.1", + "starlette>=0.40.0,<0.46.0", + "tomli>=2.0.1; python_version < '3.11'", + "uvicorn>=0.27.0", +] [build-system] requires = ["hatchling"]