Replace arcade.client with arcadepy (#119)

Closes: https://app.clickup.com/t/86b2k2962

---------

Co-authored-by: sdreyer <sterling@arcade-ai.com>
This commit is contained in:
Nate Barbettini 2024-10-23 15:29:02 -07:00 committed by GitHub
parent fd8190e216
commit 9d00295e33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 132 additions and 1391 deletions

View file

@ -8,6 +8,8 @@ from typing import Any, Optional
from urllib.parse import urlencode
import typer
from arcadepy import Arcade
from arcadepy.types import AuthorizationResponse
from openai import OpenAI, OpenAIError
from rich.console import Console
from rich.markup import escape
@ -36,7 +38,6 @@ from arcade.cli.utils import (
log_engine_health,
validate_and_get_config,
)
from arcade.client import Arcade
cli = typer.Typer(
cls=OrderCommands,
@ -260,7 +261,8 @@ def chat(
history.append({"role": "user", "content": user_input})
try:
openai_client = OpenAI(api_key=config.api.key, base_url=config.engine_url)
# TODO fixup configuration to remove this + "/v1" workaround
openai_client = OpenAI(api_key=config.api.key, base_url=config.engine_url + "/v1")
chat_result = handle_chat_interaction(
openai_client, model, history, user_email, stream
)
@ -273,7 +275,7 @@ def chat(
if tool_authorization and is_authorization_pending(tool_authorization):
chat_result = handle_tool_authorization(
client,
tool_authorization,
AuthorizationResponse.model_validate(tool_authorization),
history,
openai_client,
model,
@ -402,7 +404,7 @@ def evals(
# Try to hit /health endpoint on engine and warn if it is down
with Arcade(api_key=config.api.key, base_url=config.engine_url) as client:
log_engine_health(client) # type: ignore[arg-type]
log_engine_health(client)
# Use the new function to load eval suites
eval_suites = load_eval_suites(eval_files)

View file

@ -2,9 +2,11 @@ import importlib.util
import webbrowser
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Union
from typing import Any, Callable, Union, cast
import typer
from arcadepy import NOT_GIVEN, APIConnectionError, APIStatusError, APITimeoutError, Arcade
from arcadepy.types import AuthorizationResponse
from openai import OpenAI
from openai.resources.chat.completions import ChatCompletionChunk, Stream
from openai.types.chat.chat_completion import Choice as ChatCompletionChoice
@ -16,9 +18,6 @@ from rich.text import Text
from typer.core import TyperGroup
from typer.models import Context
from arcade.client.client import Arcade
from arcade.client.errors import APITimeoutError, EngineNotHealthyError, EngineOfflineError
from arcade.client.schema import AuthResponse
from arcade.core.catalog import ToolCatalog
from arcade.core.config_model import Config
from arcade.core.errors import ToolkitLoadError
@ -96,7 +95,14 @@ def get_tools_from_engine(
) -> list[ToolDefinition]:
config = get_config_with_overrides(force_tls, force_no_tls, host, port)
client = Arcade(api_key=config.api.key, base_url=config.engine_url)
return client.tools.list_tools(toolkit=toolkit)
tools = []
# TODO: This is a hack! limit=100 is a workaround for broken(?) pagination in Stainless
for page in client.tools.list(limit=100, toolkit=toolkit or NOT_GIVEN).iter_pages():
for item in page:
tools.append(ToolDefinition.model_validate(item.model_dump()))
return tools
def get_tool_messages(choice: dict) -> list[dict]:
@ -231,9 +237,22 @@ def apply_config_overrides(
def log_engine_health(client: Arcade) -> None:
try:
client.health.check()
result = client.health.check(timeout=2)
if result.healthy:
return
except EngineNotHealthyError as e:
console.print(
"⚠️ Warning: Arcade Engine is unhealthy",
style="bold yellow",
)
except APIConnectionError:
console.print(
"⚠️ Warning: Arcade Engine was unreachable. (Is it running?)",
style="bold yellow",
)
except APIStatusError as e:
console.print(
"[bold][yellow]⚠️ Warning: "
+ str(e)
@ -244,11 +263,6 @@ def log_engine_health(client: Arcade) -> None:
+ "[/red]"
+ "[yellow])[/yellow][/bold]"
)
except EngineOfflineError:
console.print(
"⚠️ Warning: Arcade Engine was unreachable. (Is it running?)",
style="bold yellow",
)
@dataclass
@ -319,7 +333,7 @@ def handle_chat_interaction(
def handle_tool_authorization(
arcade_client: Arcade,
tool_authorization: dict,
tool_authorization: AuthorizationResponse,
history: list[dict[str, Any]],
openai_client: OpenAI,
model: str,
@ -327,8 +341,8 @@ def handle_tool_authorization(
stream: bool,
) -> ChatInteractionResult:
with Live(console=console, refresh_per_second=4) as live:
if "authorization_url" in tool_authorization:
authorization_url = str(tool_authorization["authorization_url"])
if tool_authorization.authorization_url:
authorization_url = str(tool_authorization.authorization_url)
webbrowser.open(authorization_url)
message = (
"You'll need to authorize this action in your browser.\n\n"
@ -346,18 +360,25 @@ def handle_tool_authorization(
return handle_chat_interaction(openai_client, model, history, user_email, stream)
def wait_for_authorization_completion(client: Arcade, tool_authorization: dict | None) -> None:
def wait_for_authorization_completion(
client: Arcade, tool_authorization: AuthorizationResponse | None
) -> None:
"""
Wait for the authorization for a tool call to complete i.e., wait for the user to click on
the approval link and authorize Arcade.
"""
if tool_authorization is None:
return
auth_response = AuthResponse.model_validate(tool_authorization)
auth_response = AuthorizationResponse.model_validate(tool_authorization)
while auth_response.status != "completed":
try:
auth_response = client.auth.status(auth_response, wait=60)
auth_response = client.auth.status(
authorization_id=cast(str, auth_response.authorization_id),
scopes=" ".join(auth_response.scopes) if auth_response.scopes else NOT_GIVEN,
wait=59,
)
except APITimeoutError:
continue

View file

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

View file

@ -1,173 +0,0 @@
from typing import Any, Generic, TypeVar
from urllib.parse import urljoin
import httpx
from httpx import Timeout
from arcade.client.errors import (
APITimeoutError,
BadRequestError,
InternalServerError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
UnauthorizedError,
)
T = TypeVar("T")
ResponseT = TypeVar("ResponseT")
class BaseResource(Generic[T]):
"""Base class for all resources."""
_path: str = ""
_version: str = "v1"
def __init__(self, client: T) -> None:
self._client = client
self._resource_path = urljoin(
self._client._base_url, # type: ignore[attr-defined]
f"{self._version}/{self._path}",
)
class BaseArcadeClient:
"""Base class for Arcade clients."""
def __init__(
self,
base_url: str | None = None,
api_key: str | None = None,
headers: dict[str, str] | None = None,
timeout: float | Timeout = 30.0,
retries: int = 1,
):
"""
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.
timeout: Request timeout in seconds.
retries: Number of retries for failed requests.
"""
if base_url is None or api_key is None:
from arcade.core.config import config
base_url = base_url or config.engine_url
api_key = api_key or config.api.key
self._base_url = base_url
self._api_key = api_key
self._headers = headers or {}
self._headers.setdefault("Authorization", f"Bearer {self._api_key}")
self._headers.setdefault("Content-Type", "application/json")
self._timeout = timeout
self._retries = retries
def _build_url(self, path: str) -> str:
"""
Build the full URL for a given path.
"""
return urljoin(self._base_url, path)
def _handle_http_error(self, e: httpx.HTTPStatusError) -> None:
error_map = {
400: BadRequestError,
401: UnauthorizedError,
403: PermissionDeniedError,
404: NotFoundError,
408: APITimeoutError,
429: RateLimitError,
500: InternalServerError,
}
status_code = e.response.status_code
error_class = error_map.get(status_code, InternalServerError)
msg = e.response.json()
if isinstance(msg, dict) and "error" in msg:
raise error_class(msg["error"], response=e.response) from None
raise error_class(msg, response=e.response) from None
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,
timeout=self._timeout,
)
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: # type: ignore[return]
"""
Make a synchronous HTTP request.
"""
url = self._build_url(path)
for attempt in range(self._retries):
try:
response = self._client.request(method, url, **kwargs)
response.raise_for_status()
return response # noqa: TRY300
except httpx.HTTPStatusError as e:
if attempt == self._retries - 1:
self._handle_http_error(e)
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,
timeout=self._timeout,
)
return self._client
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: # type: ignore[return]
"""
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 as e:
if attempt == self._retries - 1:
self._handle_http_error(e)
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

@ -1,430 +0,0 @@
import json
from typing import Any, TypeVar, Union
from httpx import Timeout
from arcade.client.base import (
AsyncArcadeClient,
BaseResource,
SyncArcadeClient,
)
from arcade.client.errors import APIStatusError, EngineNotHealthyError, EngineOfflineError
from arcade.client.schema import (
AuthProvider,
AuthProviderType,
AuthRequest,
AuthResponse,
ExecuteToolResponse,
HealthCheckResponse,
)
from arcade.core.schema import ToolDefinition
T = TypeVar("T")
ClientT = TypeVar("ClientT", SyncArcadeClient, AsyncArcadeClient)
class AuthResource(BaseResource[ClientT]):
"""Authentication resource."""
_path = "/auth"
def authorize(
self,
user_id: str,
provider: AuthProvider | str,
provider_type: AuthProviderType = AuthProviderType.oauth2,
scopes: list[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.
"""
auth_provider_type = provider_type.value
body = {
"auth_requirement": {
"provider_id": provider.value if isinstance(provider, AuthProvider) else provider,
"provider_type": auth_provider_type,
auth_provider_type: AuthRequest(scopes=scopes or []).model_dump(exclude_none=True),
},
"user_id": user_id,
}
data = self._client._execute_request( # type: ignore[attr-defined]
"POST",
f"{self._resource_path}/authorize",
json=body,
)
return AuthResponse(**data)
def status(
self,
auth_id_or_response: Union[str, AuthResponse],
scopes: list[str] | None = None,
wait: int | None = None,
) -> AuthResponse:
"""
Poll for the status of an authorization
Polls using either the authorization ID or the data returned from the authorize method.
Example:
auth_response = client.auth.authorize(...)
auth_status = client.auth.poll_authorization(auth_response)
auth_status = client.auth.poll_authorization("auth_123", ["scope1", "scope2"])
"""
if isinstance(auth_id_or_response, AuthResponse):
auth_id = auth_id_or_response.auth_id
scopes = auth_id_or_response.scopes
else:
auth_id = auth_id_or_response
# Calculate the new timeout based on the wait parameter
new_timeout = self._client._timeout
if wait is not None:
if isinstance(self._client._timeout, Timeout):
new_timeout = Timeout(
connect=self._client._timeout.connect,
read=(self._client._timeout.read or 0) + wait,
write=self._client._timeout.write,
pool=self._client._timeout.pool,
)
else:
new_timeout = self._client._timeout + wait
data = self._client._execute_request( # type: ignore[attr-defined]
"GET",
f"{self._resource_path}/status",
params={
"authorizationId": auth_id,
"scopes": " ".join(scopes) if scopes else None,
"wait": wait,
},
timeout=new_timeout,
)
return AuthResponse(**data)
class ToolResource(BaseResource[ClientT]):
"""Tool resource."""
_path = "/tools"
def run(
self,
tool_name: str,
user_id: str,
tool_version: str | None = None,
inputs: dict[str, Any] | str | 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.
"""
if not isinstance(inputs, str):
try:
inputs = json.dumps(inputs)
except Exception:
raise ValueError("Inputs must be a valid JSON object or serializable dictionary")
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._resource_path}/execute", json=request_data
)
return ExecuteToolResponse(**data)
def get(self, director_id: str, tool_id: str) -> ToolDefinition:
"""
Get the specification for a tool.
"""
data = self._client._execute_request( # type: ignore[attr-defined]
"GET",
f"{self._resource_path}/definition",
params={"directorId": director_id, "toolId": tool_id},
)
return ToolDefinition(**data)
def authorize(
self, tool_name: str, user_id: str, tool_version: str | None = None
) -> AuthResponse:
"""
Get the authorization status for a tool.
"""
data = self._client._execute_request( # type: ignore[attr-defined]
"POST",
f"{self._resource_path}/authorize",
json={"tool_name": tool_name, "tool_version": tool_version, "user_id": user_id},
)
return AuthResponse(**data)
def list_tools(self, toolkit: str | None = None) -> list[ToolDefinition]:
"""
List the tools available for a given toolkit and provider.
"""
data = self._client._execute_request( # type: ignore[attr-defined]
"GET",
f"{self._resource_path}/list",
params={"toolkit": toolkit},
)
return [ToolDefinition(**tool) for tool in data]
class HealthResource(BaseResource[ClientT]):
"""Health check resource."""
_path = "/health"
def check(self) -> None:
"""
Check the health of the Arcade Engine.
Raises an error if the health check fails.
"""
try:
data = self._client._execute_request( # type: ignore[attr-defined]
"GET",
f"{self._resource_path}",
timeout=5,
)
except APIStatusError as e:
raise EngineNotHealthyError(
"Arcade Engine health check returned an unhealthy status code",
status_code=e.status_code,
)
except Exception as e:
# Catches everything else including httpx.ConnectError (most common)
raise EngineOfflineError(f"Arcade Engine was unreachable: {e}")
health_check_response = HealthCheckResponse(**data)
# Raise an error if the health payload is not `healthy: true`
if health_check_response.healthy is not True:
raise EngineNotHealthyError(
"Arcade Engine health check was not healthy",
status_code=200,
)
class AsyncAuthResource(BaseResource[AsyncArcadeClient]):
"""Asynchronous Authentication resource."""
_path = "/auth"
async def authorize(
self,
user_id: str,
provider: AuthProvider | str,
provider_type: AuthProviderType = AuthProviderType.oauth2,
scopes: list[str] | None = None,
) -> AuthResponse:
"""
Initiate an asynchronous authorization request.
"""
auth_provider_type = provider_type.value
body = {
"auth_requirement": {
"provider_id": provider.value if isinstance(provider, AuthProvider) else provider,
"provider_type": auth_provider_type,
auth_provider_type: AuthRequest(scopes=scopes or []).model_dump(exclude_none=True),
},
"user_id": user_id,
}
data = await self._client._execute_request( # type: ignore[attr-defined]
"POST",
f"{self._resource_path}/authorize",
json=body,
)
return AuthResponse(**data)
async def status(
self,
auth_id_or_response: Union[str, AuthResponse],
scopes: list[str] | None = None,
wait: int | None = None,
) -> AuthResponse:
"""
Poll for the status of an authorization asynchronously
Polls using either the authorization ID or the data returned from the authorize method.
Example:
auth_response = await client.auth.authorize(...)
auth_status = await client.auth.poll_authorization(auth_response)
auth_status = await client.auth.poll_authorization("auth_123", ["scope1", "scope2"])
"""
if isinstance(auth_id_or_response, AuthResponse):
auth_id = auth_id_or_response.auth_id
scopes = auth_id_or_response.scopes
else:
auth_id = auth_id_or_response
# Calculate the new timeout based on the wait parameter
new_timeout = self._client._timeout
if wait is not None:
if isinstance(self._client._timeout, Timeout):
new_timeout = Timeout(
connect=self._client._timeout.connect,
read=(self._client._timeout.read or 0) + wait,
write=self._client._timeout.write,
pool=self._client._timeout.pool,
)
else:
new_timeout = self._client._timeout + wait
data = await self._client._execute_request( # type: ignore[attr-defined]
"GET",
f"{self._resource_path}/status",
params={"authorizationId": auth_id, "scopes": " ".join(scopes) if scopes else None},
timeout=new_timeout,
)
return AuthResponse(**data)
class AsyncToolResource(BaseResource[AsyncArcadeClient]):
"""Asynchronous Tool resource."""
_path = "/tools"
async def run(
self,
tool_name: str,
user_id: str,
tool_version: str | None = None,
inputs: dict[str, Any] | None = None,
) -> ExecuteToolResponse:
"""
Send an asynchronous request to execute a tool and return the response.
"""
request_data = {
"tool_name": tool_name,
"user_id": user_id,
"tool_version": tool_version,
"inputs": inputs,
}
data = await self._client._execute_request( # type: ignore[attr-defined]
"POST", f"{self._resource_path}/execute", json=request_data
)
return ExecuteToolResponse(**data)
async def get(self, director_id: str, tool_id: str) -> ToolDefinition:
"""
Get the specification for a tool asynchronously.
"""
data = await self._client._execute_request( # type: ignore[attr-defined]
"GET",
f"{self._resource_path}/definition",
params={"directorId": director_id, "toolId": tool_id},
)
return ToolDefinition(**data)
async def authorize(
self, tool_name: str, user_id: str, tool_version: str | None = None
) -> AuthResponse:
"""
Get the authorization status for a tool.
"""
data = await self._client._execute_request( # type: ignore[attr-defined]
"POST",
f"{self._resource_path}/authorize",
json={"tool_name": tool_name, "tool_version": tool_version, "user_id": user_id},
)
return AuthResponse(**data)
async def list_tools(self, toolkit: str | None = None) -> list[ToolDefinition]:
"""
List the tools available for a given toolkit and provider.
"""
data = await self._client._execute_request( # type: ignore[attr-defined]
"GET",
f"{self._resource_path}/list",
params={"toolkit": toolkit},
)
return [ToolDefinition(**tool) for tool in data]
class AsyncHealthResource(BaseResource[AsyncArcadeClient]):
"""Asynchronous Health check resource."""
_path = "/health"
async def check(self) -> None:
"""
Check the health of the Arcade Engine.
Raises an error if the health check fails.
"""
try:
data = await self._client._execute_request( # type: ignore[attr-defined]
"GET",
f"{self._resource_path}",
timeout=5,
)
except APIStatusError as e:
raise EngineNotHealthyError(
"Arcade Engine health check returned an unhealthy status code",
status_code=e.status_code,
)
except Exception as e:
# Catches everything else including httpx.ConnectError (most common)
raise EngineOfflineError(f"Arcade Engine was unreachable: {e}")
health_check_response = HealthCheckResponse(**data)
# Raise an error if the health payload is not `healthy: true`
if health_check_response.healthy is not True:
raise EngineNotHealthyError(
"Arcade Engine health check was not healthy",
status_code=200,
)
class Arcade(SyncArcadeClient):
"""Synchronous Arcade client."""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.auth: AuthResource = AuthResource(self)
self.tools: ToolResource = ToolResource(self)
self.health: HealthResource = HealthResource(self)
def _execute_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""
Execute a synchronous request.
"""
response = self._request(method, url, **kwargs)
return response.json()
class AsyncArcade(AsyncArcadeClient):
"""Asynchronous Arcade client."""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.auth: AsyncAuthResource = AsyncAuthResource(self)
self.tools: AsyncToolResource = AsyncToolResource(self)
self.health: AsyncHealthResource = AsyncHealthResource(self)
async def _execute_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""
Execute an asynchronous request.
"""
response = await self._request(method, url, **kwargs)
return response.json()

View file

@ -1,101 +0,0 @@
from typing import Optional
import httpx
class ArcadeError(Exception):
"""Top-level exception for Arcade Client errors."""
pass
class EngineOfflineError(ArcadeError):
"""Raised when the Arcade Engine is offline."""
def __init__(self, message: str):
super().__init__(message)
class EngineNotHealthyError(ArcadeError):
"""Raised when the Arcade Engine is not healthy."""
def __init__(
self,
message: str,
status_code: int,
):
super().__init__(message)
self.status_code = status_code
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 APITimeoutError(APIStatusError):
"""408 Request Timeout"""
status_code = 408
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

@ -1,86 +0,0 @@
from enum import Enum
from pydantic import BaseModel, Field
from arcade.core.schema import ToolAuthorizationContext, ToolCallOutput
class AuthProvider(str, Enum):
google = "google"
"""Google authorization"""
slack = "slack_user"
"""Slack (user token) authorization"""
github = "github"
"""GitHub authorization"""
class AuthProviderType(str, Enum):
"""The supported authorization provider types."""
oauth2 = "oauth2"
"""OAuth 2.0 authorization"""
class AuthRequest(BaseModel):
"""
The requirements for authorization for a tool
# TODO (Nate): Make a validator here
"""
scopes: 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 HealthCheckResponse(BaseModel):
"""Response from a health check request."""
healthy: bool
"""Whether the health check was successful."""
class AuthResponse(BaseModel):
"""Response from an authorization request."""
auth_id: str = Field(alias="authorization_id")
"""The ID of the authorization request"""
scopes: list[str]
"""The scope(s) requested in the authorization request"""
# TODO: Use AnyUrl?
auth_url: str | None = Field(None, alias="authorization_url")
"""The URL for the authorization"""
status: AuthStatus
"""Only completed implies presence of a token"""
context: ToolAuthorizationContext | 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

@ -129,8 +129,6 @@ class Config(BaseConfig):
6. Hostnames with underscores (common in development environments) are supported.
7. Pre-existing port specifications in the host are respected.
The resulting URL is always suffixed with '/v1' to specify the API version.
Returns:
str: The fully constructed URL for the Arcade Engine.
@ -175,14 +173,14 @@ class Config(BaseConfig):
if ":" in parsed_host.netloc and not is_ip:
host, existing_port = parsed_host.netloc.rsplit(":", 1)
if existing_port.isdigit():
return f"{protocol}://{parsed_host.netloc}/{self.api.version}"
return f"{protocol}://{parsed_host.netloc}"
if is_fqdn and self.engine.port is None:
return f"{protocol}://{encoded_host}/{self.api.version}"
return f"{protocol}://{encoded_host}"
elif self.engine.port is not None:
return f"{protocol}://{encoded_host}:{self.engine.port}/{self.api.version}"
return f"{protocol}://{encoded_host}:{self.engine.port}"
else:
return f"{protocol}://{encoded_host}/{self.api.version}"
return f"{protocol}://{encoded_host}"
@classmethod
def ensure_config_dir_exists(cls) -> None:

View file

@ -1,152 +0,0 @@
import dataclasses
from enum import Enum
from typing import Any
class CustomCodeBase(Enum):
"""Custom status code base class"""
@property
def code(self) -> Any:
"""
Get status code
"""
return self.value[0]
@property
def msg(self) -> Any:
"""
Get status code information
"""
return self.value[1]
class CustomResponseCode(CustomCodeBase):
"""Custom response status codes"""
HTTP_200 = (200, "Request Successful")
HTTP_201 = (201, "Created Successfully")
HTTP_202 = (202, "Request Accepted, but Processing Not Yet Complete")
HTTP_204 = (204, "Request Successful, but No Content Returned")
HTTP_400 = (400, "Bad Request")
HTTP_401 = (401, "Unauthorized")
HTTP_403 = (403, "Forbidden Access")
HTTP_404 = (404, "Requested Resource Not Found")
HTTP_410 = (410, "Requested Resource Permanently Deleted")
HTTP_422 = (422, "Invalid Request Parameters")
HTTP_425 = (425, "Request Unexecutable, as Server Cannot Meet Requirements")
HTTP_429 = (429, "Too Many Requests, Server Limiting")
HTTP_500 = (500, "Internal Server Error")
HTTP_502 = (502, "Gateway Error")
HTTP_503 = (503, "Server Temporarily Unable to Process Request")
HTTP_504 = (504, "Gateway Timeout")
@dataclasses.dataclass
class CustomResponse:
"""
Provides open response status codes, rather than enums, which can be useful if you want to customize response information
"""
code: int
msg: str
class StandardResponseCode:
"""Standard response status codes"""
"""
HTTP codes
See HTTP Status Code Registry:
https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
And RFC 2324 - https://tools.ietf.org/html/rfc2324
"""
HTTP_100 = 100 # CONTINUE
HTTP_101 = 101 # SWITCHING_PROTOCOLS
HTTP_102 = 102 # PROCESSING
HTTP_103 = 103 # EARLY_HINTS
HTTP_200 = 200 # OK
HTTP_201 = 201 # CREATED
HTTP_202 = 202 # ACCEPTED
HTTP_203 = 203 # NON_AUTHORITATIVE_INFORMATION
HTTP_204 = 204 # NO_CONTENT
HTTP_205 = 205 # RESET_CONTENT
HTTP_206 = 206 # PARTIAL_CONTENT
HTTP_207 = 207 # MULTI_STATUS
HTTP_208 = 208 # ALREADY_REPORTED
HTTP_226 = 226 # IM_USED
HTTP_300 = 300 # MULTIPLE_CHOICES
HTTP_301 = 301 # MOVED_PERMANENTLY
HTTP_302 = 302 # FOUND
HTTP_303 = 303 # SEE_OTHER
HTTP_304 = 304 # NOT_MODIFIED
HTTP_305 = 305 # USE_PROXY
HTTP_307 = 307 # TEMPORARY_REDIRECT
HTTP_308 = 308 # PERMANENT_REDIRECT
HTTP_400 = 400 # BAD_REQUEST
HTTP_401 = 401 # UNAUTHORIZED
HTTP_402 = 402 # PAYMENT_REQUIRED
HTTP_403 = 403 # FORBIDDEN
HTTP_404 = 404 # NOT_FOUND
HTTP_405 = 405 # METHOD_NOT_ALLOWED
HTTP_406 = 406 # NOT_ACCEPTABLE
HTTP_407 = 407 # PROXY_AUTHENTICATION_REQUIRED
HTTP_408 = 408 # REQUEST_TIMEOUT
HTTP_409 = 409 # CONFLICT
HTTP_410 = 410 # GONE
HTTP_411 = 411 # LENGTH_REQUIRED
HTTP_412 = 412 # PRECONDITION_FAILED
HTTP_413 = 413 # REQUEST_ENTITY_TOO_LARGE
HTTP_414 = 414 # REQUEST_URI_TOO_LONG
HTTP_415 = 415 # UNSUPPORTED_MEDIA_TYPE
HTTP_416 = 416 # REQUESTED_RANGE_NOT_SATISFIABLE
HTTP_417 = 417 # EXPECTATION_FAILED
HTTP_418 = 418 # UNUSED
HTTP_421 = 421 # MISDIRECTED_REQUEST
HTTP_422 = 422 # UNPROCESSABLE_CONTENT
HTTP_423 = 423 # LOCKED
HTTP_424 = 424 # FAILED_DEPENDENCY
HTTP_425 = 425 # TOO_EARLY
HTTP_426 = 426 # UPGRADE_REQUIRED
HTTP_427 = 427 # UNASSIGNED
HTTP_428 = 428 # PRECONDITION_REQUIRED
HTTP_429 = 429 # TOO_MANY_REQUESTS
HTTP_430 = 430 # Unassigned
HTTP_431 = 431 # REQUEST_HEADER_FIELDS_TOO_LARGE
HTTP_451 = 451 # UNAVAILABLE_FOR_LEGAL_REASONS
HTTP_500 = 500 # INTERNAL_SERVER_ERROR
HTTP_501 = 501 # NOT_IMPLEMENTED
HTTP_502 = 502 # BAD_GATEWAY
HTTP_503 = 503 # SERVICE_UNAVAILABLE
HTTP_504 = 504 # GATEWAY_TIMEOUT
HTTP_505 = 505 # HTTP_VERSION_NOT_SUPPORTED
HTTP_506 = 506 # VARIANT_ALSO_NEGOTIATES
HTTP_507 = 507 # INSUFFICIENT_STORAGE
HTTP_508 = 508 # LOOP_DETECTED
HTTP_509 = 509 # UNASSIGNED
HTTP_510 = 510 # NOT_EXTENDED
HTTP_511 = 511 # NETWORK_AUTHENTICATION_REQUIRED
"""
WebSocket codes
https://www.iana.org/assignments/websocket/websocket.xml#close-code-number
https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
"""
WS_1000 = 1000 # NORMAL_CLOSURE
WS_1001 = 1001 # GOING_AWAY
WS_1002 = 1002 # PROTOCOL_ERROR
WS_1003 = 1003 # UNSUPPORTED_DATA
WS_1005 = 1005 # NO_STATUS_RCVD
WS_1006 = 1006 # ABNORMAL_CLOSURE
WS_1007 = 1007 # INVALID_FRAME_PAYLOAD_DATA
WS_1008 = 1008 # POLICY_VIOLATION
WS_1009 = 1009 # MESSAGE_TOO_BIG
WS_1010 = 1010 # MANDATORY_EXT
WS_1011 = 1011 # INTERNAL_ERROR
WS_1012 = 1012 # SERVICE_RESTART
WS_1013 = 1013 # TRY_AGAIN_LATER
WS_1014 = 1014 # BAD_GATEWAY
WS_1015 = 1015 # TLS_HANDSHAKE
WS_3000 = 3000 # UNAUTHORIZED
WS_3003 = 3003 # FORBIDDEN

View file

@ -654,7 +654,7 @@ def tool_eval() -> Callable[[Callable], Callable]:
results = []
async with AsyncOpenAI(
api_key=config.api.key,
base_url=config.engine_url,
base_url=config.engine_url + "/v1", # TODO remove
) as client:
result = await suite.run(client, model)
results.append(result)

View file

@ -20,6 +20,7 @@ rich = "^13.7.1"
toml = "^0.10.2"
tomlkit = "^0.12.4"
openai = "^1.36.0" # TODO: relax to an earlier version that still has what we need
arcadepy = "~0.1.0"
pyjwt = "^2.8.0"
loguru = "^0.7.0"
types-python-dateutil = "2.9.0.20241003"

View file

@ -1,396 +0,0 @@
from unittest.mock import AsyncMock, Mock
import pytest
from httpx import HTTPStatusError, Response
from arcade.client import Arcade, AsyncArcade, AuthProvider
from arcade.client.errors import (
APITimeoutError,
BadRequestError,
EngineNotHealthyError,
InternalServerError,
NotFoundError,
PermissionDeniedError,
UnauthorizedError,
)
from arcade.client.schema import AuthProviderType, AuthResponse, ExecuteToolResponse
from arcade.core.schema import ToolDefinition
AUTH_RESPONSE_DATA = {
"auth_id": "auth_123",
"authorization_url": "https://example.com/auth",
"status": "pending",
"authorization_id": "auth_123",
"scopes": ["https://www.googleapis.com/auth/gmail.readonly"],
}
AUTH_RESPONSE_DATA_NO_SCOPES = {
"auth_id": "auth_123",
"authorization_url": "https://example.com/auth",
"status": "pending",
"authorization_id": "auth_123",
"scopes": [],
}
TOOL_RESPONSE_DATA = {
"tool_name": "GetEmails",
"tool_version": "0.1.0",
"output": {"result": "Hello, World!"},
"error": None,
"invocation_id": "inv_123",
"duration": 1.5,
"finished_at": "2023-04-01T12:00:00Z",
"success": True,
}
TOOL_DEFINITION_DATA = {
"name": "GetEmails",
"fully_qualified_name": "TestToolkit.GetEmails",
"description": "Retrieve emails from a user's inbox",
"toolkit": {
"name": "TestToolkit",
"version": "0.1.0",
"description": "A toolkit for testing",
},
"input_schema": {"type": "object", "properties": {"n_emails": {"type": "integer"}}},
"output_schema": {"type": "array", "items": {"type": "string"}},
"version": "0.1.0",
"inputs": {"parameters": []},
"output": {},
"requirements": {"auth_requirements": []},
}
TOOL_AUTHORIZE_RESPONSE_DATA = {
"authorization_id": "auth_456",
"authorization_url": "https://example.com/auth",
"scopes": ["scope1", "scope2"],
"status": "pending",
}
HEALTH_CHECK_HEALTHY_RESPONSE_DATA = {
"healthy": True,
}
HEALTH_CHECK_UNHEALTHY_RESPONSE_DATA = {
"healthy": False,
"reason": "Cannot reticulate splines",
}
@pytest.fixture
def test_sync_client():
"""Test client."""
return Arcade(base_url="http://arcade.example.com", api_key="fake_api_key")
@pytest.fixture
def test_async_client():
"""Test client."""
return AsyncArcade(base_url="http://arcade.example.com", api_key="fake_api_key")
@pytest.fixture
def mock_response():
"""Mock Response object for testing."""
response = Mock(spec=Response)
response.json.return_value = {}
return response
@pytest.fixture
def mock_async_response():
"""Mock AsyncResponse object for testing."""
response = AsyncMock(spec=Response)
response.json.return_value = {}
return response
@pytest.mark.parametrize(
"error_code, expected_error",
[
(400, BadRequestError),
(401, UnauthorizedError),
(403, PermissionDeniedError),
(404, NotFoundError),
(408, APITimeoutError),
(500, InternalServerError),
],
)
def test_handle_http_error(test_sync_client, error_code, expected_error, mock_response):
"""Test _handle_http_error method for different error codes."""
mock_response.status_code = error_code
mock_response.json.return_value = {"error": "Test error message"}
# Create a mock HTTPStatusError
mock_http_error = Mock(spec=HTTPStatusError)
mock_http_error.response = mock_response
with pytest.raises(expected_error):
test_sync_client._handle_http_error(mock_http_error) # Call the method on the instance
def test_arcade_auth_authorize(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.auth.authorize method."""
monkeypatch.setattr(Arcade, "_execute_request", lambda *args, **kwargs: AUTH_RESPONSE_DATA)
auth_response = test_sync_client.auth.authorize(
provider=AuthProvider.google,
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
user_id="sam@arcade-ai.com",
)
assert auth_response == AuthResponse(**AUTH_RESPONSE_DATA)
def test_arcade_auth_authorize_with_provider_type(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.auth.authorize method."""
monkeypatch.setattr(Arcade, "_execute_request", lambda *args, **kwargs: AUTH_RESPONSE_DATA)
auth_response = test_sync_client.auth.authorize(
provider="hooli",
provider_type=AuthProviderType.oauth2,
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
user_id="sam@arcade-ai.com",
)
assert auth_response == AuthResponse(**AUTH_RESPONSE_DATA)
def test_arcade_auth_authorize_with_no_scopes(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.auth.authorize method."""
monkeypatch.setattr(
Arcade, "_execute_request", lambda *args, **kwargs: AUTH_RESPONSE_DATA_NO_SCOPES
)
auth_response = test_sync_client.auth.authorize(
provider=AuthProvider.google,
user_id="sam@arcade-ai.com",
)
assert auth_response == AuthResponse(**AUTH_RESPONSE_DATA_NO_SCOPES)
def test_arcade_auth_poll_authorization(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.auth.poll_authorization method."""
monkeypatch.setattr(Arcade, "_execute_request", lambda *args, **kwargs: AUTH_RESPONSE_DATA)
auth_response = test_sync_client.auth.status("auth_123")
assert auth_response == AuthResponse(**AUTH_RESPONSE_DATA)
def test_arcade_auth_long_poll_authorization(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.auth.poll_authorization method with long polling."""
monkeypatch.setattr(Arcade, "_execute_request", lambda *args, **kwargs: AUTH_RESPONSE_DATA)
auth_response = test_sync_client.auth.status("auth_123", wait=1)
assert auth_response == AuthResponse(**AUTH_RESPONSE_DATA)
def test_arcade_tool_run(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.tools.run method."""
monkeypatch.setattr(Arcade, "_execute_request", lambda *args, **kwargs: TOOL_RESPONSE_DATA)
tool_response = test_sync_client.tools.run(
tool_name="GetEmails",
user_id="sam@arcade-ai.com",
tool_version="0.1.0",
inputs={"n_emails": 5},
)
assert tool_response == ExecuteToolResponse(**TOOL_RESPONSE_DATA)
def test_arcade_tool_get(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.tools.get method."""
monkeypatch.setattr(Arcade, "_execute_request", lambda *args, **kwargs: TOOL_DEFINITION_DATA)
tool_definition = test_sync_client.tools.get(director_id="default", tool_id="GetEmails")
assert tool_definition == ToolDefinition(**TOOL_DEFINITION_DATA)
def test_arcade_tool_authorize(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.tools.authorize method."""
monkeypatch.setattr(
Arcade, "_execute_request", lambda *args, **kwargs: TOOL_AUTHORIZE_RESPONSE_DATA
)
auth_response = test_sync_client.tools.authorize(
tool_name="GetEmails", user_id="sam@arcade-ai.com"
)
assert auth_response == AuthResponse(**TOOL_AUTHORIZE_RESPONSE_DATA)
def test_arcade_health_check(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.health.check method."""
monkeypatch.setattr(
Arcade, "_execute_request", lambda *args, **kwargs: HEALTH_CHECK_HEALTHY_RESPONSE_DATA
)
test_sync_client.health.check()
assert True # If no exception is raised, the test passes
def test_arcade_health_check_raises_error(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.health.check method."""
monkeypatch.setattr(
Arcade, "_execute_request", lambda *args, **kwargs: HEALTH_CHECK_UNHEALTHY_RESPONSE_DATA
)
with pytest.raises(EngineNotHealthyError):
test_sync_client.health.check()
@pytest.mark.asyncio
async def test_async_arcade_auth_authorize(test_async_client, mock_async_response, monkeypatch):
"""Test AsyncArcade.auth.authorize method."""
async def mock_execute_request(*args, **kwargs):
return AUTH_RESPONSE_DATA
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
auth_response = await test_async_client.auth.authorize(
provider=AuthProvider.google,
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
user_id="sam@arcade-ai.com",
)
assert auth_response == AuthResponse(**AUTH_RESPONSE_DATA)
@pytest.mark.asyncio
async def test_async_arcade_auth_authorize_with_provider_type(
test_async_client, mock_async_response, monkeypatch
):
"""Test AsyncArcade.auth.authorize method."""
async def mock_execute_request(*args, **kwargs):
return AUTH_RESPONSE_DATA
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
auth_response = await test_async_client.auth.authorize(
provider="hooli",
provider_type=AuthProviderType.oauth2,
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
user_id="sam@arcade-ai.com",
)
assert auth_response == AuthResponse(**AUTH_RESPONSE_DATA)
@pytest.mark.asyncio
async def test_async_arcade_auth_authorize_with_no_scopes(
test_async_client, mock_async_response, monkeypatch
):
"""Test AsyncArcade.auth.authorize method."""
async def mock_execute_request(*args, **kwargs):
return AUTH_RESPONSE_DATA_NO_SCOPES
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
auth_response = await test_async_client.auth.authorize(
provider=AuthProvider.google,
user_id="sam@arcade-ai.com",
)
assert auth_response == AuthResponse(**AUTH_RESPONSE_DATA_NO_SCOPES)
@pytest.mark.asyncio
async def test_async_arcade_auth_poll_authorization(
test_async_client, mock_async_response, monkeypatch
):
"""Test AsyncArcade.auth.poll_authorization method."""
async def mock_execute_request(*args, **kwargs):
return AUTH_RESPONSE_DATA
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
auth_response = await test_async_client.auth.status("auth_123")
assert auth_response == AuthResponse(**AUTH_RESPONSE_DATA)
@pytest.mark.asyncio
async def test_async_arcade_auth_long_poll_authorization(
test_async_client, mock_async_response, monkeypatch
):
"""Test AsyncArcade.auth.poll_authorization method with long polling."""
async def mock_execute_request(*args, **kwargs):
return AUTH_RESPONSE_DATA
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
auth_response = await test_async_client.auth.status("auth_123", wait=1)
assert auth_response == AuthResponse(**AUTH_RESPONSE_DATA)
@pytest.mark.asyncio
async def test_async_arcade_tool_run(test_async_client, mock_async_response, monkeypatch):
"""Test AsyncArcade.tools.run method."""
async def mock_execute_request(*args, **kwargs):
return TOOL_RESPONSE_DATA
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
tool_response = await test_async_client.tools.run(
tool_name="GetEmails",
user_id="sam@arcade-ai.com",
tool_version="0.1.0",
inputs={"n_emails": 5},
)
assert tool_response == ExecuteToolResponse(**TOOL_RESPONSE_DATA)
@pytest.mark.asyncio
async def test_async_arcade_tool_get(test_async_client, mock_async_response, monkeypatch):
"""Test AsyncArcade.tools.get method."""
async def mock_execute_request(*args, **kwargs):
return TOOL_DEFINITION_DATA
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
tool_definition = await test_async_client.tools.get(director_id="default", tool_id="GetEmails")
assert tool_definition == ToolDefinition(**TOOL_DEFINITION_DATA)
@pytest.mark.asyncio
async def test_async_arcade_tool_authorize(test_async_client, mock_async_response, monkeypatch):
"""Test AsyncArcade.tools.authorize method."""
async def mock_execute_request(*args, **kwargs):
return TOOL_AUTHORIZE_RESPONSE_DATA
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
auth_response = await test_async_client.tools.authorize(
tool_name="GetEmails", user_id="sam@arcade-ai.com"
)
assert auth_response == AuthResponse(**TOOL_AUTHORIZE_RESPONSE_DATA)
@pytest.mark.asyncio
async def test_async_arcade_health_check(test_async_client, mock_async_response, monkeypatch):
"""Test AsyncArcade.health.check method."""
async def mock_execute_request(*args, **kwargs):
return HEALTH_CHECK_HEALTHY_RESPONSE_DATA
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
await test_async_client.health.check()
assert True # If no exception is raised, the test passes
@pytest.mark.asyncio
async def test_async_arcade_health_check_raises_error(
test_async_client, mock_async_response, monkeypatch
):
"""Test AsyncArcade.health.check method."""
async def mock_execute_request(*args, **kwargs):
return HEALTH_CHECK_UNHEALTHY_RESPONSE_DATA
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
with pytest.raises(EngineNotHealthyError):
await test_async_client.health.check()
def test_arcade_tool_list_tools(test_sync_client, mock_response, monkeypatch):
"""Test Arcade.tools.list_tools method."""
data = [TOOL_DEFINITION_DATA]
monkeypatch.setattr(Arcade, "_execute_request", lambda *args, **kwargs: data)
tool_definitions = test_sync_client.tools.list_tools(toolkit="TestToolkit")
assert tool_definitions == [ToolDefinition(**TOOL_DEFINITION_DATA)]
@pytest.mark.asyncio
async def test_async_arcade_tool_list_tools(test_async_client, mock_async_response, monkeypatch):
"""Test AsyncArcade.tools.list_tools method."""
data = [TOOL_DEFINITION_DATA]
async def mock_execute_request(*args, **kwargs):
return data
monkeypatch.setattr(AsyncArcade, "_execute_request", mock_execute_request)
tool_definitions = await test_async_client.tools.list_tools(toolkit="TestToolkit")
assert tool_definitions == [ToolDefinition(**TOOL_DEFINITION_DATA)]

View file

@ -4,6 +4,7 @@ ignorePaths:
dictionaryDefinitions: []
dictionaries: []
words:
- arcadepy
- conlist
- fastapi
- httpx

View file

@ -0,0 +1,26 @@
from arcadepy import Arcade
client = Arcade(
base_url="http://localhost:9099",
)
user_id = "you@example.com"
# Start the authorization process
auth_response = client.tools.authorize(
tool_name="Google.ListEmails",
user_id=user_id,
)
if auth_response.status != "completed":
print(f"Click this link to authorize: {auth_response.authorization_url}")
input("After you have authorized, press Enter to continue...")
inputs = {"n_emails": 5}
response = client.tools.execute(
tool_name="Google.ListEmails",
inputs=inputs,
user_id=user_id,
)
print(response)

View file

@ -0,0 +1,34 @@
from arcadepy import Arcade
from arcadepy.types.auth_authorize_params import AuthRequirement, AuthRequirementOauth2
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
client = Arcade(
base_url="http://localhost:9099",
)
user_id = "you@example.com"
# Start the authorization process
auth_response = client.auth.authorize(
auth_requirement=AuthRequirement(
provider_id="google",
oauth2=AuthRequirementOauth2(
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
),
),
user_id=user_id,
)
if auth_response.status != "completed":
print(f"Click this link to authorize: {auth_response.authorization_url}")
input("After you have authorized, press Enter to continue...")
# Use the token from the authorization response
creds = Credentials(auth_response.context.token)
service = build("gmail", "v1", credentials=creds)
# Now you can use the Google API
results = service.users().labels().list(userId="me").execute()
labels = results.get("labels", [])
print("Labels:", labels)

View file

@ -2,17 +2,17 @@ import os
import arcade_math
from fastapi import FastAPI, HTTPException
from openai import AsyncOpenAI
from pydantic import BaseModel
from arcade.actor.fastapi.actor import FastAPIActor
from arcade.client import AsyncArcade
from arcade.core.config import config
from arcade.core.toolkit import Toolkit
if not config.api or not config.api.key:
raise ValueError("Arcade API key not set. Please run `arcade login`.")
client = AsyncArcade(api_key=config.api.key)
client = AsyncOpenAI(api_key=config.api.key, base_url="http://localhost:9099/v1")
app = FastAPI()

View file

@ -1,5 +1,7 @@
import time # Import time for polling delays
from typing import cast
from arcadepy import NOT_GIVEN, Arcade
from arcadepy.types.auth_authorize_params import AuthRequirement, AuthRequirementOauth2
from google.oauth2.credentials import Credentials
from langchain_google_community import GmailToolkit
from langchain_google_community.gmail.utils import (
@ -13,28 +15,33 @@ from langgraph.prebuilt import create_react_agent
# %pip install -qU langchain-google-community[gmail]
# %pip install -qU langchain-openai
# %pip install -qU langgraph
from arcade.client import Arcade, AuthProvider
client = Arcade()
# Start the authorization process for the tool "ListEmails"
auth_response = client.auth.authorize(
provider=AuthProvider.google,
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
auth_requirement=AuthRequirement(
provider_id="google",
oauth2=AuthRequirementOauth2(
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
),
),
user_id="sam@arcade-ai.com",
)
# If authorization is not completed, prompt the user and poll for status
if auth_response.status != "completed":
print("Please complete the authorization challenge in your browser before continuing:")
print(auth_response.auth_url)
print(auth_response.authorization_url)
input("Press Enter to continue...")
# Poll for authorization status using the auth polling method
while auth_response.status != "completed":
# Wait before polling again to avoid spamming the server
time.sleep(4)
auth_response = client.auth.status(auth_response)
auth_response = client.auth.status(
authorization_id=cast(str, auth_response.authorization_id),
scopes=" ".join(auth_response.scopes) if auth_response.scopes else NOT_GIVEN,
wait=30, # Long poll
)
# Authorization is completed; proceed with obtaining credentials
creds = Credentials(auth_response.context.token)

View file

@ -1,13 +1,11 @@
import json
import os
from typing import Any, TypedDict
from arcadepy import Arcade
from langgraph.checkpoint.memory import MemorySaver
from langgraph.errors import NodeInterrupt
from langgraph.graph import END, START, StateGraph
from arcade.client import Arcade
client = Arcade(api_key=os.environ["ARCADE_API_KEY"])
@ -26,11 +24,10 @@ def step_1(state: State, config) -> State:
if challenge.status != "completed":
raise NodeInterrupt(f"Please visit this URL to authorize: {challenge.auth_url}")
result = client.tools.run(
result = client.tools.execute(
tool_name="ListEmails",
user_id=user_id,
tool_version="default",
inputs=json.dumps({"n_emails": 5}),
inputs={"n_emails": 5},
)
return {"emails": result}