Arcade Client Implementation: Sync and Async (#22)

This PR introduces both synchronous and asynchronous Arcade client
implementations, providing a robust interface for interacting with the
Arcade API.

## Key Features

1. Synchronous (`SyncArcade`) and Asynchronous (`AsyncArcade`) clients
2. Authentication and Tool resources
3. OpenAI chat completions integration
4. Comprehensive error handling

## Client Methods

Both `SyncArcade` and `AsyncArcade` offer:

- `auth.authorize()`: Initiate authorization
- `auth.poll_authorization()`: Check authorization status
- `tool.run()`: Execute a tool
- `tool.get()`: Retrieve tool specification
- `chat.create()`: Create chat completions

## Usage Examples

### Synchronous Authorization

```python
from arcade.client import AuthProvider, SyncArcade

client = SyncArcade(base_url="https://api.arcade.com", api_key="your_api_key")
auth_response = client.auth.authorize(
    provider=AuthProvider.google,
    scopes=["https://www.googleapis.com/auth/gmail.readonly"],
    user_id="user123"
)
print(f"Authorize at: {auth_response.auth_url}")
```

### Asynchronous Authorization

```python
import asyncio
from arcade.client import AuthProvider, AsyncArcade

async def authorize():
    client = AsyncArcade(base_url="https://api.arcade.com", api_key="your_api_key")
    auth_response = await client.auth.authorize(
        provider=AuthProvider.slack_user,
        scopes=["chat:write", "im:write"],
        user_id="user456"
    )
    print(f"Authorize at: {auth_response.auth_url}")

asyncio.run(authorize())
```

This implementation provides a flexible and powerful way to interact
with Arcade services, supporting both synchronous and asynchronous
workflows.
This commit is contained in:
Sam Partee 2024-08-28 17:24:43 -07:00 committed by GitHub
parent 07d9cca2ab
commit 1c1403d1dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 727 additions and 67 deletions

View file

@ -82,7 +82,7 @@ class BaseActor(Actor):
duration_ms = (end_time - start_time) * 1000 # Convert to milliseconds
return ToolCallResponse(
invocation_id=tool_request.invocation_id,
invocation_id=tool_request.invocation_id or "",
duration=duration_ms,
finished_at=datetime.now().isoformat(),
success=not output.error,

View file

@ -42,14 +42,23 @@ class Actor(ABC):
@abstractmethod
def get_catalog(self) -> list[ToolDefinition]:
"""
Get the catalog of tools available in the actor.
"""
pass
@abstractmethod
async def call_tool(self, request: ToolCallRequest) -> ToolCallResponse:
"""
Send a request to call a tool to the Actor
"""
pass
@abstractmethod
def health_check(self) -> dict[str, Any]:
"""
Perform a health check of the actor
"""
pass

View file

@ -49,7 +49,6 @@ class FastAPIRouter(Router):
method=request.method,
body_json=body_json,
)
if is_async_callable(handler):
return await handler(request_data)
else:

View file

@ -0,0 +1,8 @@
from arcade.client.client import Arcade, AsyncArcade
from arcade.client.schema import AuthProvider
__all__ = [
"AuthProvider",
"AsyncArcade",
"Arcade",
]

View file

@ -0,0 +1,150 @@
import os
from typing import Any, Generic, TypeVar
from urllib.parse import urljoin
import httpx
from httpx import Timeout
from arcade.core.config import config
T = TypeVar("T")
ResponseT = TypeVar("ResponseT")
class BaseResource(Generic[T]):
"""Base class for all resources."""
def __init__(self, client: T):
self._client = client
class BaseArcadeClient:
"""Base class for Arcade clients."""
def __init__(
self,
base_url: str,
api_key: str | None = None,
headers: dict[str, str] | None = None,
proxies: str | dict[str, str] | None = None,
timeout: float | Timeout = 10.0,
retries: int = 3,
):
"""
Initialize the BaseArcadeClient.
Args:
base_url: The base URL for the Arcade API.
api_key: The API key for authentication.
headers: Additional headers to include in requests.
proxies: Proxy configuration for requests.
timeout: Request timeout in seconds.
retries: Number of retries for failed requests.
"""
self._base_url = base_url
self._api_key = api_key or os.environ.get("ARCADE_API_KEY") or config.api.key
self._headers = headers or {}
self._headers.setdefault("X-API-Key", self._api_key)
self._headers.setdefault("Content-Type", "application/json")
self._proxies = proxies
self._timeout = timeout
self._retries = retries
def _build_url(self, path: str) -> str:
"""
Build the full URL for a given path.
Args:
path: The path to append to the base URL.
Returns:
The full URL.
"""
return urljoin(self._base_url, path)
class SyncArcadeClient(BaseArcadeClient):
"""Synchronous Arcade client."""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self._client = httpx.Client(
base_url=self._base_url,
headers=self._headers,
proxies=self._proxies,
timeout=self._timeout,
)
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
"""
Make a synchronous HTTP request.
"""
url = self._build_url(path)
for attempt in range(self._retries):
try:
print(method, url, kwargs)
response = self._client.request(method, url, **kwargs)
response.raise_for_status()
return response # noqa: TRY300
except httpx.HTTPStatusError:
if attempt == self._retries - 1:
raise
raise RuntimeError("This should never be reached")
def close(self) -> None:
"""Close the client session."""
self._client.close()
def __enter__(self) -> "SyncArcadeClient":
return self
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self.close()
class AsyncArcadeClient(BaseArcadeClient):
"""Asynchronous Arcade client."""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
"""
Get or create an asynchronous HTTP client.
"""
if self._client is None:
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers=self._headers,
proxies=self._proxies,
timeout=self._timeout,
)
return self._client
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
"""
Make an asynchronous HTTP request.
"""
client = await self._get_client()
url = self._build_url(path)
for attempt in range(self._retries):
try:
response = await client.request(method, url, **kwargs)
response.raise_for_status()
return response # noqa: TRY300
except httpx.HTTPStatusError:
if attempt == self._retries - 1:
raise
raise RuntimeError("This should never be reached")
async def close(self) -> None:
"""Close the client session."""
if self._client:
await self._client.aclose()
async def __aenter__(self) -> "AsyncArcadeClient":
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
await self.close()

View file

@ -0,0 +1,215 @@
from typing import Any, Generic, TypeVar
import httpx
from openai import AsyncOpenAI, OpenAI
from openai.resources.chat import AsyncChat, Chat
from arcade.client.base import AsyncArcadeClient, BaseResource, SyncArcadeClient
from arcade.client.errors import (
APIStatusError,
BadRequestError,
InternalServerError,
NotFoundError,
PermissionDeniedError,
UnauthorizedError,
)
from arcade.client.schema import (
AuthProvider,
AuthRequest,
AuthResponse,
ExecuteToolResponse,
)
from arcade.core.schema import ToolDefinition
T = TypeVar("T")
ClientT = TypeVar("ClientT", SyncArcadeClient, AsyncArcadeClient)
class AuthResource(BaseResource[ClientT]):
"""Authentication resource."""
_base_path = "/v1/auth"
def authorize(
self,
provider: AuthProvider,
scopes: list[str],
user_id: str,
authority: str | None = None,
) -> AuthResponse:
"""
Initiate an authorization request.
Args:
provider: The authorization provider.
scopes: The scopes required for the authorization.
user_id: The user ID initiating the authorization.
authority: The authority initiating the authorization.
"""
auth_provider = provider.value
body = {
"auth_requirement": {
"provider": auth_provider,
auth_provider: AuthRequest(scope=scopes, authority=authority).dict(
exclude_none=True
),
},
"user_id": user_id,
}
data = self._client._execute_request( # type: ignore[attr-defined]
"POST",
f"{self._base_path}/authorize",
json=body,
)
return AuthResponse(**data)
def poll_authorization(self, auth_id: str) -> AuthResponse:
"""
Poll for the status of an authorization request.
Args:
auth_id: The authorization ID.
Example:
auth_status = client.auth.poll_authorization("auth_123")
"""
data = self._client._execute_request( # type: ignore[attr-defined]
"GET", f"{self._base_path}/status", params={"authorizationID": auth_id}
)
return AuthResponse(**data)
class ToolResource(BaseResource[ClientT]):
"""Tool resource."""
_base_path = "/v1/tools"
def run(
self,
tool_name: str,
user_id: str,
tool_version: str | None = None,
inputs: dict[str, Any] | None = None,
) -> ExecuteToolResponse:
"""
Send a request to execute a tool and return the response.
Args:
tool_name: The name of the tool to execute.
user_id: The user ID initiating the tool execution.
tool_version: The version of the tool to execute (if not provided, the latest version will be used).
inputs: The inputs for the tool.
"""
request_data = {
"tool_name": tool_name,
"user_id": user_id,
"tool_version": tool_version,
"inputs": inputs,
}
data = self._client._execute_request( # type: ignore[attr-defined]
"POST", f"{self._base_path}/execute", json=request_data
)
return ExecuteToolResponse(**data)
def get(self, director_id: str, tool_id: str) -> ToolDefinition:
"""
Get the specification for a tool.
Args:
director_id: The director ID.
tool_id: The tool ID.
"""
data = self._client._execute_request( # type: ignore[attr-defined]
"GET",
f"{self._base_path}/definition",
params={"director_id": director_id, "tool_id": tool_id},
)
return ToolDefinition(**data)
class ArcadeClientMixin(Generic[ClientT]):
"""Mixin for Arcade clients."""
def __init__(self, base_url: str, *args: Any, **kwargs: Any):
super().__init__(base_url, *args, **kwargs)
self._openai_client: OpenAI | AsyncOpenAI | None = None
self.auth: AuthResource = AuthResource(self)
self.tool: ToolResource = ToolResource(self)
self.chat: Chat | AsyncChat | None = None
def _handle_http_error(
self,
e: httpx.HTTPStatusError,
error_map: dict[int, type[APIStatusError]],
) -> None:
status_code = e.response.status_code
error_class = error_map.get(status_code, InternalServerError)
raise error_class(str(e), response=e.response)
class Arcade(ArcadeClientMixin[SyncArcadeClient], SyncArcadeClient):
"""Synchronous Arcade client."""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self._openai_client = OpenAI(base_url=self._base_url + "/v1")
self.chat = self._openai_client.chat
def _execute_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""
Execute a synchronous request.
Args:
method: The HTTP method.
url: The URL to request.
**kwargs: Additional arguments for the request.
"""
try:
response = self._request(method, url, **kwargs)
return response.json()
except httpx.HTTPStatusError as e:
self._handle_http_error(
e,
{
400: BadRequestError,
401: UnauthorizedError,
403: PermissionDeniedError,
404: NotFoundError,
500: InternalServerError,
},
)
class AsyncArcade(ArcadeClientMixin[AsyncArcadeClient], AsyncArcadeClient):
"""Asynchronous Arcade client."""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self._openai_client = AsyncOpenAI(base_url=self._base_url + "/v1")
self.chat = self._openai_client.chat
async def _execute_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""
Execute an asynchronous request.
Args:
method: The HTTP method.
url: The URL to request.
**kwargs: Additional arguments for the request.
"""
try:
response = await self._request(method, url, **kwargs)
return response.json()
except httpx.HTTPStatusError as e:
self._handle_http_error(
e,
{
400: BadRequestError,
401: UnauthorizedError,
403: PermissionDeniedError,
404: NotFoundError,
500: InternalServerError,
},
)

View file

@ -0,0 +1,76 @@
from typing import Optional
import httpx
class ArcadeError(Exception):
"""Top-level exception for Arcade Client errors."""
pass
class APIError(ArcadeError):
"""Base class for API-related errors."""
def __init__(self, message: str, request: httpx.Request, *, body: Optional[object] = None):
super().__init__(message)
self.message = message
self.request = request
self.body = body
class APIStatusError(APIError):
"""Raised when an API response has a status code of 4xx or 5xx."""
def __init__(self, message: str, *, response: httpx.Response, body: Optional[object] = None):
super().__init__(message, response.request, body=body)
self.response = response
self.status_code = response.status_code
class BadRequestError(APIStatusError):
"""400 Bad Request"""
status_code = 400
class UnauthorizedError(APIStatusError):
"""401 Unauthorized"""
status_code = 401
class PermissionDeniedError(APIStatusError):
"""403 Forbidden"""
status_code = 403
class NotFoundError(APIStatusError):
"""404 Not Found"""
status_code = 404
class RateLimitError(APIStatusError):
"""429 Too Many Requests"""
status_code = 429
class InternalServerError(APIStatusError):
"""500 Internal Server Error"""
status_code = 500
class UnprocessableEntityError(APIStatusError):
"""422 Unprocessable Entity"""
status_code = 422
class ServiceUnavailableError(APIStatusError):
"""503 Service Unavailable"""
status_code = 503

View file

@ -0,0 +1,76 @@
from enum import Enum
from pydantic import AnyUrl, BaseModel, Field
from arcade.core.schema import ToolCallOutput, ToolContext
class AuthProvider(str, Enum):
"""The supported authorization providers."""
oauth2 = "oauth2"
"""OAuth 2.0 authorization"""
google = "google"
"""Google authorization"""
slack_user = "slack_user"
"""Slack (user token) authorization"""
github_app = "github_app"
"""GitHub App authorization"""
class AuthRequest(BaseModel):
"""
The requirements for authorization for a tool
"""
authority: AnyUrl | None = None
"""The URL of the OAuth 2.0 authorization server."""
scope: list[str]
"""The scope(s) needed for authorization."""
class AuthStatus(str, Enum):
"""The status of an authorization request."""
pending = "pending"
failed = "failed"
completed = "completed"
class AuthResponse(BaseModel):
"""Response from an authorization request."""
auth_id: str = Field(alias="authorizationID")
"""The ID of the authorization request"""
# TODO: Use AnyUrl?
auth_url: str | None = Field(None, alias="authorizationURL")
"""The URL for the authorization"""
status: AuthStatus
"""Only completed implies presence of a token"""
context: ToolContext | None = None
class ExecuteToolResponse(BaseModel):
"""Response from executing a tool."""
invocation_id: str
"""The globally-unique ID for this tool invocation in the run."""
duration: float
"""The duration of the tool invocation in milliseconds."""
finished_at: str
"""The timestamp when the tool invocation finished."""
success: bool
"""Whether the tool invocation was successful."""
output: ToolCallOutput | None = None
"""The output of the tool invocation."""

View file

@ -86,6 +86,10 @@ class MaterializedTool(BaseModel):
def description(self) -> str:
return self.definition.description
@property
def requires_auth(self) -> bool:
return self.definition.requirements.authorization is not None
class ToolCatalog(BaseModel):
"""Singleton class that holds all tools for a given actor"""

View file

@ -147,11 +147,11 @@ class ToolContext(BaseModel):
class ToolCallRequest(BaseModel):
"""The request to call (invoke) a tool."""
run_id: str
run_id: str | None = None
"""The globally-unique run ID provided by the Engine."""
invocation_id: str
invocation_id: str | None = None
"""The globally-unique ID for this tool invocation in the run."""
created_at: str
created_at: str | None = None
"""The timestamp when the tool invocation was created."""
tool: ToolVersion
"""The name and version of the tool."""

View file

@ -0,0 +1,65 @@
import os
from google.oauth2.credentials import Credentials
from langchain_google_community import GmailToolkit
from langchain_google_community.gmail.utils import (
build_resource_service,
)
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
# Step 1: Install required packages
# Run the following in your terminal:
# %pip install -qU langchain-google-community[gmail]
# %pip install -qU langchain-openai
# %pip install -qU langgraph
#
# Step 2: Set environment variables for LangChain and OpenAI API keys
# Uncomment the following lines if you have the LangSmith API key
# os.environ["LANGCHAIN_TRACING_V2"] = "true"
# os.environ["LANGCHAIN_API_KEY"] = getpass.getpass("Enter your LangSmith API key: ")
# Step 3: Authenticate with Gmail
# credentials = get_gmail_credentials(
# token_file="token.json",
# scopes=["https://mail.google.com/"],
# client_secrets_file="credentials.json",
# )
# alternative way to authenticate with arcade
from arcade.client import Arcade, AuthProvider
client = Arcade(base_url="http://localhost:9099", api_key=os.environ["ARCADE_API_KEY"])
challenge = client.auth.authorize(
provider=AuthProvider.google,
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
user_id="example_user_id",
)
if challenge.status != "completed":
print(f"Please visit this URL to authorize: {challenge.auth_url}")
input("Press Enter after you've completed the authorization...")
challenge = client.auth.poll_authorization(challenge.auth_id)
if challenge.status != "completed":
print("Authorization not completed. Please try again.")
exit(1)
creds = Credentials(challenge.context.authorization.token)
api_resource = build_resource_service(credentials=creds)
toolkit = GmailToolkit(api_resource=api_resource)
# Step 4: Get available tools
tools = toolkit.get_tools()
# Step 5: Initialize the LLM and create an agent
llm = ChatOpenAI(model="gpt-4o")
agent_executor = create_react_agent(llm, tools)
# Step 6: Draft an email using the agent
example_query = "Read my latest emails to me and summarize them."
events = agent_executor.stream(
{"messages": [("user", example_query)]},
stream_mode="values",
)
for event in events:
event["messages"][-1].pretty_print()

View file

@ -1,8 +1,9 @@
import base64
from email.mime.text import MIMEText
import json
import re
from base64 import urlsafe_b64decode
from typing import Annotated
from email.mime.text import MIMEText
from typing import Annotated, Any, Dict, Optional
from bs4 import BeautifulSoup
from google.oauth2.credentials import Credentials
@ -55,75 +56,134 @@ def get_draft_url(draft_id):
async def get_emails(
context: ToolContext,
n_emails: Annotated[int, "Number of emails to read"] = 5,
) -> dict[str, list[dict[str, str]]]:
"""Read emails from a Gmail account and extract plain text content, removing any HTML."""
) -> Annotated[str, "A list of email details in JSON format"]:
"""
Read emails from a Gmail account and extract plain text content.
# Build the Gmail service using the provided OAuth2 token
Args:
context (ToolContext): The context containing authorization information.
n_emails (int): Number of emails to read (default: 5).
Returns:
Dict[str, List[Dict[str, str]]]: A dictionary containing a list of email details.
"""
service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
# Request a list of all the messages
result = service.users().messages().list(userId="me").execute()
messages = result.get("messages")
try:
messages = (
service.users().messages().list(userId="me").execute().get("messages", [])
)
# If there are no messages, return an empty string
if not messages:
return ""
if not messages:
return {"emails": []}
emails = []
emails = []
for msg in messages[:n_emails]:
try:
email_data = (
service.users().messages().get(userId="me", id=msg["id"]).execute()
)
email_details = parse_email(email_data)
if email_details:
emails.append(email_details)
except Exception as e:
print(f"Error reading email {msg['id']}: {e}")
for msg in messages[:n_emails]:
txt = service.users().messages().get(userId="me", id=msg["id"]).execute()
return json.dumps({"emails": emails})
try:
payload = txt["payload"]
headers = payload["headers"]
except Exception as e:
print(f"Error reading emails: {e}")
return "Error reading emails"
for d in headers:
if d["name"] == "From":
from_ = d["value"]
if d["name"] == "Date":
date = d["value"]
if d["name"] == "Subject":
subject = d["value"]
else:
subject = "No subject"
data = None
parts = payload.get("parts")
if parts:
part = parts[0]
body = part.get("body")
if body:
data = body.get("data")
if data:
data = urlsafe_b64decode(data).decode()
def parse_email(email_data: Dict[str, Any]) -> Optional[Dict[str, str]]:
"""
Parse email data and extract relevant information.
email_details = {
"from": from_,
"date": date,
"subject": subject,
"body": clean_email_body(data) if data else "",
}
emails.append(email_details)
Args:
email_data (Dict[str, Any]): Raw email data from Gmail API.
except Exception as e:
print(f"Error reading email {msg['id']}: {e}", "ERROR")
continue
Returns:
Optional[Dict[str, str]]: Parsed email details or None if parsing fails.
"""
try:
payload = email_data["payload"]
headers = {d["name"].lower(): d["value"] for d in payload["headers"]}
return {"emails": emails}
body_data = get_email_body(payload)
return {
"from": headers.get("from", ""),
"date": headers.get("date", ""),
"subject": headers.get("subject", "No subject"),
"body": clean_email_body(body_data) if body_data else "",
}
except Exception as e:
print(f"Error parsing email {email_data.get('id', 'unknown')}: {e}")
return None
def get_email_body(payload: Dict[str, Any]) -> Optional[str]:
"""
Extract email body from payload.
Args:
payload (Dict[str, Any]): Email payload data.
Returns:
Optional[str]: Decoded email body or None if not found.
"""
if "body" in payload and payload["body"].get("data"):
return urlsafe_b64decode(payload["body"]["data"]).decode()
for part in payload.get("parts", []):
if part.get("mimeType") == "text/plain" and "data" in part["body"]:
return urlsafe_b64decode(part["body"]["data"]).decode()
return None
def clean_email_body(body: str) -> str:
"""Remove HTML tags and non-sentence elements from email body text."""
"""
Remove HTML tags and clean up email body text while preserving most content.
# Remove HTML tags using BeautifulSoup
soup = BeautifulSoup(body, "html.parser")
text = soup.get_text(separator=" ")
Args:
body (str): The raw email body text.
# Remove any non-sentence elements (e.g., URLs, email addresses, etc.)
text = re.sub(r"\S*@\S*\s?", "", text) # Remove emails
text = re.sub(r"http\S+", "", text) # Remove URLs
text = re.sub(r"[^.!?a-zA-Z0-9\s]", "", text) # Remove non-sentence characters
text = " ".join(text.split()) # Remove extra whitespace
Returns:
str: Cleaned email body text.
"""
try:
# Remove HTML tags using BeautifulSoup
soup = BeautifulSoup(body, "html.parser")
text = soup.get_text(separator=" ")
# Clean up the text
text = clean_text(text)
return text.strip()
except Exception as e:
print(f"Error cleaning email body: {e}")
return body
def clean_text(text: str) -> str:
"""
Clean up the text while preserving most content.
Args:
text (str): The input text.
Returns:
str: Cleaned text.
"""
# Replace multiple newlines with a single newline
text = re.sub(r"\n+", "\n", text)
# Replace multiple spaces with a single space
text = re.sub(r"\s+", " ", text)
# Remove leading/trailing whitespace from each line
text = "\n".join(line.strip() for line in text.split("\n"))
return text

View file

@ -1,16 +1,16 @@
from typing import Annotated
from arcade.core.errors import ToolExecutionError, RetryableToolError
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from arcade.core.errors import RetryableToolError, ToolExecutionError
from arcade.core.schema import ToolContext
from arcade.sdk import tool
from arcade.sdk.auth import SlackUser
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
@tool(
requires_auth=SlackUser(
# TODO reduce this to chat:write, im:write, users.profile:read, users:read
# when incremental auth works
scope=[
"chat:write",
"im:write",
@ -74,8 +74,6 @@ def format_users(userListResponse: dict) -> str:
@tool(
requires_auth=SlackUser(
# TODO reduce this to chat:write, channels:read, groups:read
# when incremental auth works
scope=[
"chat:write",
"im:write",