diff --git a/arcade/arcade/cli/main.py b/arcade/arcade/cli/main.py index 81018eb2..b291f431 100644 --- a/arcade/arcade/cli/main.py +++ b/arcade/arcade/cli/main.py @@ -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) diff --git a/arcade/arcade/cli/utils.py b/arcade/arcade/cli/utils.py index 89d0df0c..d2798378 100644 --- a/arcade/arcade/cli/utils.py +++ b/arcade/arcade/cli/utils.py @@ -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 diff --git a/arcade/arcade/client/__init__.py b/arcade/arcade/client/__init__.py deleted file mode 100644 index 357496d8..00000000 --- a/arcade/arcade/client/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from arcade.client.client import Arcade, AsyncArcade -from arcade.client.schema import AuthProvider - -__all__ = [ - "AuthProvider", - "AsyncArcade", - "Arcade", -] diff --git a/arcade/arcade/client/base.py b/arcade/arcade/client/base.py deleted file mode 100644 index 9d19069e..00000000 --- a/arcade/arcade/client/base.py +++ /dev/null @@ -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() diff --git a/arcade/arcade/client/client.py b/arcade/arcade/client/client.py deleted file mode 100644 index 71b1d388..00000000 --- a/arcade/arcade/client/client.py +++ /dev/null @@ -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() diff --git a/arcade/arcade/client/errors.py b/arcade/arcade/client/errors.py deleted file mode 100644 index c95d8dc1..00000000 --- a/arcade/arcade/client/errors.py +++ /dev/null @@ -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 diff --git a/arcade/arcade/client/schema.py b/arcade/arcade/client/schema.py deleted file mode 100644 index 27658f8c..00000000 --- a/arcade/arcade/client/schema.py +++ /dev/null @@ -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.""" diff --git a/arcade/arcade/core/config_model.py b/arcade/arcade/core/config_model.py index 75e558f0..11a7ca02 100644 --- a/arcade/arcade/core/config_model.py +++ b/arcade/arcade/core/config_model.py @@ -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: diff --git a/arcade/arcade/core/response_code.py b/arcade/arcade/core/response_code.py deleted file mode 100644 index 6c7dc277..00000000 --- a/arcade/arcade/core/response_code.py +++ /dev/null @@ -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 diff --git a/arcade/arcade/sdk/eval/eval.py b/arcade/arcade/sdk/eval/eval.py index 78bc6f65..5284f8e5 100644 --- a/arcade/arcade/sdk/eval/eval.py +++ b/arcade/arcade/sdk/eval/eval.py @@ -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) diff --git a/arcade/pyproject.toml b/arcade/pyproject.toml index 0eff8b01..78736677 100644 --- a/arcade/pyproject.toml +++ b/arcade/pyproject.toml @@ -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" diff --git a/arcade/tests/client/test_client.py b/arcade/tests/client/test_client.py deleted file mode 100644 index 38704fb4..00000000 --- a/arcade/tests/client/test_client.py +++ /dev/null @@ -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)] diff --git a/cspell.config.yaml b/cspell.config.yaml index add8d12a..fb21d3f2 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -4,6 +4,7 @@ ignorePaths: dictionaryDefinitions: [] dictionaries: [] words: + - arcadepy - conlist - fastapi - httpx diff --git a/examples/bareclient/get_gmail_emails.py b/examples/bareclient/get_gmail_emails.py new file mode 100644 index 00000000..6ddb16d0 --- /dev/null +++ b/examples/bareclient/get_gmail_emails.py @@ -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) diff --git a/examples/bareclient/get_gmail_token.py b/examples/bareclient/get_gmail_token.py new file mode 100644 index 00000000..5df9ebdc --- /dev/null +++ b/examples/bareclient/get_gmail_token.py @@ -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) diff --git a/examples/fastapi/arcade_example_fastapi/main.py b/examples/fastapi/arcade_example_fastapi/main.py index 75bf595f..085203f7 100644 --- a/examples/fastapi/arcade_example_fastapi/main.py +++ b/examples/fastapi/arcade_example_fastapi/main.py @@ -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() diff --git a/examples/langchain/langgraph_auth.py b/examples/langchain/langgraph_auth.py index 0312a294..a3bde5fc 100644 --- a/examples/langchain/langgraph_auth.py +++ b/examples/langchain/langgraph_auth.py @@ -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) diff --git a/examples/langchain/langgraph_with_tool_exec.py b/examples/langchain/langgraph_with_tool_exec.py index f98c69cb..f2280f29 100644 --- a/examples/langchain/langgraph_with_tool_exec.py +++ b/examples/langchain/langgraph_with_tool_exec.py @@ -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}