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:
parent
fd8190e216
commit
9d00295e33
18 changed files with 132 additions and 1391 deletions
|
|
@ -8,6 +8,8 @@ from typing import Any, Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
from arcadepy import Arcade
|
||||||
|
from arcadepy.types import AuthorizationResponse
|
||||||
from openai import OpenAI, OpenAIError
|
from openai import OpenAI, OpenAIError
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
|
|
@ -36,7 +38,6 @@ from arcade.cli.utils import (
|
||||||
log_engine_health,
|
log_engine_health,
|
||||||
validate_and_get_config,
|
validate_and_get_config,
|
||||||
)
|
)
|
||||||
from arcade.client import Arcade
|
|
||||||
|
|
||||||
cli = typer.Typer(
|
cli = typer.Typer(
|
||||||
cls=OrderCommands,
|
cls=OrderCommands,
|
||||||
|
|
@ -260,7 +261,8 @@ def chat(
|
||||||
history.append({"role": "user", "content": user_input})
|
history.append({"role": "user", "content": user_input})
|
||||||
|
|
||||||
try:
|
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(
|
chat_result = handle_chat_interaction(
|
||||||
openai_client, model, history, user_email, stream
|
openai_client, model, history, user_email, stream
|
||||||
)
|
)
|
||||||
|
|
@ -273,7 +275,7 @@ def chat(
|
||||||
if tool_authorization and is_authorization_pending(tool_authorization):
|
if tool_authorization and is_authorization_pending(tool_authorization):
|
||||||
chat_result = handle_tool_authorization(
|
chat_result = handle_tool_authorization(
|
||||||
client,
|
client,
|
||||||
tool_authorization,
|
AuthorizationResponse.model_validate(tool_authorization),
|
||||||
history,
|
history,
|
||||||
openai_client,
|
openai_client,
|
||||||
model,
|
model,
|
||||||
|
|
@ -402,7 +404,7 @@ def evals(
|
||||||
|
|
||||||
# Try to hit /health endpoint on engine and warn if it is down
|
# 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:
|
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
|
# Use the new function to load eval suites
|
||||||
eval_suites = load_eval_suites(eval_files)
|
eval_suites = load_eval_suites(eval_files)
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import importlib.util
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Union
|
from typing import Any, Callable, Union, cast
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
from arcadepy import NOT_GIVEN, APIConnectionError, APIStatusError, APITimeoutError, Arcade
|
||||||
|
from arcadepy.types import AuthorizationResponse
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
from openai.resources.chat.completions import ChatCompletionChunk, Stream
|
from openai.resources.chat.completions import ChatCompletionChunk, Stream
|
||||||
from openai.types.chat.chat_completion import Choice as ChatCompletionChoice
|
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.core import TyperGroup
|
||||||
from typer.models import Context
|
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.catalog import ToolCatalog
|
||||||
from arcade.core.config_model import Config
|
from arcade.core.config_model import Config
|
||||||
from arcade.core.errors import ToolkitLoadError
|
from arcade.core.errors import ToolkitLoadError
|
||||||
|
|
@ -96,7 +95,14 @@ def get_tools_from_engine(
|
||||||
) -> list[ToolDefinition]:
|
) -> list[ToolDefinition]:
|
||||||
config = get_config_with_overrides(force_tls, force_no_tls, host, port)
|
config = get_config_with_overrides(force_tls, force_no_tls, host, port)
|
||||||
client = Arcade(api_key=config.api.key, base_url=config.engine_url)
|
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]:
|
def get_tool_messages(choice: dict) -> list[dict]:
|
||||||
|
|
@ -231,9 +237,22 @@ def apply_config_overrides(
|
||||||
|
|
||||||
def log_engine_health(client: Arcade) -> None:
|
def log_engine_health(client: Arcade) -> None:
|
||||||
try:
|
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(
|
console.print(
|
||||||
"[bold][yellow]⚠️ Warning: "
|
"[bold][yellow]⚠️ Warning: "
|
||||||
+ str(e)
|
+ str(e)
|
||||||
|
|
@ -244,11 +263,6 @@ def log_engine_health(client: Arcade) -> None:
|
||||||
+ "[/red]"
|
+ "[/red]"
|
||||||
+ "[yellow])[/yellow][/bold]"
|
+ "[yellow])[/yellow][/bold]"
|
||||||
)
|
)
|
||||||
except EngineOfflineError:
|
|
||||||
console.print(
|
|
||||||
"⚠️ Warning: Arcade Engine was unreachable. (Is it running?)",
|
|
||||||
style="bold yellow",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -319,7 +333,7 @@ def handle_chat_interaction(
|
||||||
|
|
||||||
def handle_tool_authorization(
|
def handle_tool_authorization(
|
||||||
arcade_client: Arcade,
|
arcade_client: Arcade,
|
||||||
tool_authorization: dict,
|
tool_authorization: AuthorizationResponse,
|
||||||
history: list[dict[str, Any]],
|
history: list[dict[str, Any]],
|
||||||
openai_client: OpenAI,
|
openai_client: OpenAI,
|
||||||
model: str,
|
model: str,
|
||||||
|
|
@ -327,8 +341,8 @@ def handle_tool_authorization(
|
||||||
stream: bool,
|
stream: bool,
|
||||||
) -> ChatInteractionResult:
|
) -> ChatInteractionResult:
|
||||||
with Live(console=console, refresh_per_second=4) as live:
|
with Live(console=console, refresh_per_second=4) as live:
|
||||||
if "authorization_url" in tool_authorization:
|
if tool_authorization.authorization_url:
|
||||||
authorization_url = str(tool_authorization["authorization_url"])
|
authorization_url = str(tool_authorization.authorization_url)
|
||||||
webbrowser.open(authorization_url)
|
webbrowser.open(authorization_url)
|
||||||
message = (
|
message = (
|
||||||
"You'll need to authorize this action in your browser.\n\n"
|
"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)
|
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
|
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.
|
the approval link and authorize Arcade.
|
||||||
"""
|
"""
|
||||||
if tool_authorization is None:
|
if tool_authorization is None:
|
||||||
return
|
return
|
||||||
auth_response = AuthResponse.model_validate(tool_authorization)
|
|
||||||
|
auth_response = AuthorizationResponse.model_validate(tool_authorization)
|
||||||
|
|
||||||
while auth_response.status != "completed":
|
while auth_response.status != "completed":
|
||||||
try:
|
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:
|
except APITimeoutError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
from arcade.client.client import Arcade, AsyncArcade
|
|
||||||
from arcade.client.schema import AuthProvider
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AuthProvider",
|
|
||||||
"AsyncArcade",
|
|
||||||
"Arcade",
|
|
||||||
]
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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."""
|
|
||||||
|
|
@ -129,8 +129,6 @@ class Config(BaseConfig):
|
||||||
6. Hostnames with underscores (common in development environments) are supported.
|
6. Hostnames with underscores (common in development environments) are supported.
|
||||||
7. Pre-existing port specifications in the host are respected.
|
7. Pre-existing port specifications in the host are respected.
|
||||||
|
|
||||||
The resulting URL is always suffixed with '/v1' to specify the API version.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The fully constructed URL for the Arcade Engine.
|
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:
|
if ":" in parsed_host.netloc and not is_ip:
|
||||||
host, existing_port = parsed_host.netloc.rsplit(":", 1)
|
host, existing_port = parsed_host.netloc.rsplit(":", 1)
|
||||||
if existing_port.isdigit():
|
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:
|
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:
|
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:
|
else:
|
||||||
return f"{protocol}://{encoded_host}/{self.api.version}"
|
return f"{protocol}://{encoded_host}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ensure_config_dir_exists(cls) -> None:
|
def ensure_config_dir_exists(cls) -> None:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -654,7 +654,7 @@ def tool_eval() -> Callable[[Callable], Callable]:
|
||||||
results = []
|
results = []
|
||||||
async with AsyncOpenAI(
|
async with AsyncOpenAI(
|
||||||
api_key=config.api.key,
|
api_key=config.api.key,
|
||||||
base_url=config.engine_url,
|
base_url=config.engine_url + "/v1", # TODO remove
|
||||||
) as client:
|
) as client:
|
||||||
result = await suite.run(client, model)
|
result = await suite.run(client, model)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ rich = "^13.7.1"
|
||||||
toml = "^0.10.2"
|
toml = "^0.10.2"
|
||||||
tomlkit = "^0.12.4"
|
tomlkit = "^0.12.4"
|
||||||
openai = "^1.36.0" # TODO: relax to an earlier version that still has what we need
|
openai = "^1.36.0" # TODO: relax to an earlier version that still has what we need
|
||||||
|
arcadepy = "~0.1.0"
|
||||||
pyjwt = "^2.8.0"
|
pyjwt = "^2.8.0"
|
||||||
loguru = "^0.7.0"
|
loguru = "^0.7.0"
|
||||||
types-python-dateutil = "2.9.0.20241003"
|
types-python-dateutil = "2.9.0.20241003"
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
|
||||||
|
|
@ -4,6 +4,7 @@ ignorePaths:
|
||||||
dictionaryDefinitions: []
|
dictionaryDefinitions: []
|
||||||
dictionaries: []
|
dictionaries: []
|
||||||
words:
|
words:
|
||||||
|
- arcadepy
|
||||||
- conlist
|
- conlist
|
||||||
- fastapi
|
- fastapi
|
||||||
- httpx
|
- httpx
|
||||||
|
|
|
||||||
26
examples/bareclient/get_gmail_emails.py
Normal file
26
examples/bareclient/get_gmail_emails.py
Normal 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)
|
||||||
34
examples/bareclient/get_gmail_token.py
Normal file
34
examples/bareclient/get_gmail_token.py
Normal 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)
|
||||||
|
|
@ -2,17 +2,17 @@ import os
|
||||||
|
|
||||||
import arcade_math
|
import arcade_math
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from openai import AsyncOpenAI
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from arcade.actor.fastapi.actor import FastAPIActor
|
from arcade.actor.fastapi.actor import FastAPIActor
|
||||||
from arcade.client import AsyncArcade
|
|
||||||
from arcade.core.config import config
|
from arcade.core.config import config
|
||||||
from arcade.core.toolkit import Toolkit
|
from arcade.core.toolkit import Toolkit
|
||||||
|
|
||||||
if not config.api or not config.api.key:
|
if not config.api or not config.api.key:
|
||||||
raise ValueError("Arcade API key not set. Please run `arcade login`.")
|
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()
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 google.oauth2.credentials import Credentials
|
||||||
from langchain_google_community import GmailToolkit
|
from langchain_google_community import GmailToolkit
|
||||||
from langchain_google_community.gmail.utils import (
|
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-google-community[gmail]
|
||||||
# %pip install -qU langchain-openai
|
# %pip install -qU langchain-openai
|
||||||
# %pip install -qU langgraph
|
# %pip install -qU langgraph
|
||||||
from arcade.client import Arcade, AuthProvider
|
|
||||||
|
|
||||||
client = Arcade()
|
client = Arcade()
|
||||||
|
|
||||||
# Start the authorization process for the tool "ListEmails"
|
# Start the authorization process for the tool "ListEmails"
|
||||||
auth_response = client.auth.authorize(
|
auth_response = client.auth.authorize(
|
||||||
provider=AuthProvider.google,
|
auth_requirement=AuthRequirement(
|
||||||
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
|
provider_id="google",
|
||||||
|
oauth2=AuthRequirementOauth2(
|
||||||
|
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
|
||||||
|
),
|
||||||
|
),
|
||||||
user_id="sam@arcade-ai.com",
|
user_id="sam@arcade-ai.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
# If authorization is not completed, prompt the user and poll for status
|
# If authorization is not completed, prompt the user and poll for status
|
||||||
if auth_response.status != "completed":
|
if auth_response.status != "completed":
|
||||||
print("Please complete the authorization challenge in your browser before continuing:")
|
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...")
|
input("Press Enter to continue...")
|
||||||
|
|
||||||
# Poll for authorization status using the auth polling method
|
# Poll for authorization status using the auth polling method
|
||||||
while auth_response.status != "completed":
|
while auth_response.status != "completed":
|
||||||
# Wait before polling again to avoid spamming the server
|
auth_response = client.auth.status(
|
||||||
time.sleep(4)
|
authorization_id=cast(str, auth_response.authorization_id),
|
||||||
auth_response = client.auth.status(auth_response)
|
scopes=" ".join(auth_response.scopes) if auth_response.scopes else NOT_GIVEN,
|
||||||
|
wait=30, # Long poll
|
||||||
|
)
|
||||||
|
|
||||||
# Authorization is completed; proceed with obtaining credentials
|
# Authorization is completed; proceed with obtaining credentials
|
||||||
creds = Credentials(auth_response.context.token)
|
creds = Credentials(auth_response.context.token)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from typing import Any, TypedDict
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
from arcadepy import Arcade
|
||||||
from langgraph.checkpoint.memory import MemorySaver
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
from langgraph.errors import NodeInterrupt
|
from langgraph.errors import NodeInterrupt
|
||||||
from langgraph.graph import END, START, StateGraph
|
from langgraph.graph import END, START, StateGraph
|
||||||
|
|
||||||
from arcade.client import Arcade
|
|
||||||
|
|
||||||
client = Arcade(api_key=os.environ["ARCADE_API_KEY"])
|
client = Arcade(api_key=os.environ["ARCADE_API_KEY"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,11 +24,10 @@ def step_1(state: State, config) -> State:
|
||||||
if challenge.status != "completed":
|
if challenge.status != "completed":
|
||||||
raise NodeInterrupt(f"Please visit this URL to authorize: {challenge.auth_url}")
|
raise NodeInterrupt(f"Please visit this URL to authorize: {challenge.auth_url}")
|
||||||
|
|
||||||
result = client.tools.run(
|
result = client.tools.execute(
|
||||||
tool_name="ListEmails",
|
tool_name="ListEmails",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
tool_version="default",
|
inputs={"n_emails": 5},
|
||||||
inputs=json.dumps({"n_emails": 5}),
|
|
||||||
)
|
)
|
||||||
return {"emails": result}
|
return {"emails": result}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue