diff --git a/Makefile b/Makefile
index 86b16d44..cfc856d4 100644
--- a/Makefile
+++ b/Makefile
@@ -26,8 +26,8 @@ build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
@cd arcade && poetry build
-.PHONY: clean
-clean: ## clean build artifacts
+.PHONY: clean-build
+clean-build: ## clean build artifacts
@cd arcade && rm -rf dist
.PHONY: publish
diff --git a/arcade/arcade/cli/authn.py b/arcade/arcade/cli/authn.py
new file mode 100644
index 00000000..68a27c87
--- /dev/null
+++ b/arcade/arcade/cli/authn.py
@@ -0,0 +1,135 @@
+import os
+import threading
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from typing import Any
+from urllib.parse import parse_qs
+
+import toml
+from rich.console import Console
+
+from arcade.cli.constants import LOGIN_FAILED_HTML, LOGIN_SUCCESS_HTML
+
+console = Console()
+
+
+class LoginCallbackHandler(BaseHTTPRequestHandler):
+ def __init__(self, *args, state: str, **kwargs): # type: ignore[no-untyped-def]
+ self.state = state # Simple CSRF protection
+ super().__init__(*args, **kwargs)
+
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 Argument `format` is shadowing a Python builtin
+ # Override to suppress logging to stdout
+ pass
+
+ def _parse_login_response(self) -> tuple[str, str, str] | None:
+ # Parse the query string from the URL
+ query_string = self.path.split("?", 1)[-1]
+ params = parse_qs(query_string)
+ returned_state = params.get("state", [None])[0]
+
+ if returned_state != self.state:
+ console.print(
+ "❌ Login failed: Invalid login attempt. Please try again.", style="bold red"
+ )
+ return None
+
+ api_key = params.get("api_key", [None])[0] or ""
+ email = params.get("email", [None])[0] or ""
+ warning = params.get("warning", [None])[0] or ""
+
+ return api_key, email, warning
+
+ def _handle_login_response(self) -> bool:
+ result = self._parse_login_response()
+ if result is None:
+ return False
+ api_key, email, warning = result
+
+ if warning:
+ console.print(warning, style="bold yellow")
+
+ # If API key and email are received, store them in a file
+ if not api_key or not email:
+ console.print(
+ "❌ Login failed: No credentials received. Please try again.", style="bold red"
+ )
+ return False
+
+ # TODO don't overwrite existing config
+ config_file_path = os.path.expanduser("~/.arcade/arcade.toml")
+ new_config = {"api": {"key": api_key}, "user": {"email": email}}
+ with open(config_file_path, "w") as f:
+ toml.dump(new_config, f)
+
+ # Send a success response to the browser
+ console.print(
+ f"""✅ Hi there, {email}!
+
+Your Arcade API key is: {api_key}
+Stored in: {config_file_path}""",
+ style="bold green",
+ )
+ return True
+
+ def do_GET(self) -> None: # This naming is correct, required by BaseHTTPRequestHandler
+ success = self._handle_login_response()
+ if success:
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(LOGIN_SUCCESS_HTML)
+ else:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(LOGIN_FAILED_HTML)
+
+ # Always shut down the server so it doesn't keep running
+ threading.Thread(target=self.server.shutdown).start()
+
+
+class LocalAuthCallbackServer:
+ def __init__(self, state: str, port: int = 9905):
+ self.state = state
+ self.port = port
+ self.httpd: HTTPServer | None = None
+
+ def run_server(self) -> None:
+ # Initialize and run the server
+ server_address = ("", self.port)
+ handler = lambda *args, **kwargs: LoginCallbackHandler(*args, state=self.state, **kwargs)
+ self.httpd = HTTPServer(server_address, handler)
+ self.httpd.serve_forever()
+
+ def shutdown_server(self) -> None:
+ # Shut down the server gracefully
+ if self.httpd:
+ self.httpd.shutdown()
+
+
+def check_existing_login() -> bool:
+ """
+ Check if the user is already logged in by verifying the config file.
+
+ Returns:
+ bool: True if the user is already logged in, False otherwise.
+ """
+ config_file_path = os.path.expanduser("~/.arcade/arcade.toml")
+ if not os.path.exists(config_file_path):
+ return False
+
+ try:
+ config: dict[str, Any] = toml.load(config_file_path)
+ api_key = config.get("api", {}).get("key")
+ email = config.get("user", {}).get("email")
+
+ if api_key and email:
+ console.print(
+ f"You're already logged in as {email}. "
+ f"Delete {config_file_path} to log in as a different user."
+ )
+ return True
+ except toml.TomlDecodeError:
+ console.print(f"Error: Invalid configuration file at {config_file_path}", style="bold red")
+ except Exception as e:
+ console.print(f"Error: Unable to read configuration file: {e!s}", style="bold red")
+
+ return False
diff --git a/arcade/arcade/cli/constants.py b/arcade/arcade/cli/constants.py
new file mode 100644
index 00000000..4f890983
--- /dev/null
+++ b/arcade/arcade/cli/constants.py
@@ -0,0 +1,143 @@
+_style_block = b"""
+
+
+
+
+
+
+
+"""
+
+LOGIN_SUCCESS_HTML = (
+ b"""
+
+
+
+
+
+ Success!
+ """
+ + _style_block
+ + b"""
+
+
+
+

+
Log in to Arcade CLI
+
Success! You can close this window.
+
+
+
+"""
+)
+
+LOGIN_FAILED_HTML = (
+ b"""
+
+
+
+
+
+ Login failed
+ """
+ + _style_block
+ + b"""
+
+
+
+

+
Log in to Arcade CLI
+
Something went wrong. Please close this window and try again.
+
+
+
+"""
+)
diff --git a/arcade/arcade/cli/main.py b/arcade/arcade/cli/main.py
index 54269b94..914a2257 100644
--- a/arcade/arcade/cli/main.py
+++ b/arcade/arcade/cli/main.py
@@ -1,46 +1,63 @@
-import asyncio
import os
+import threading
+import uuid
+import webbrowser
from typing import Any, Optional
+from urllib.parse import urlencode
import typer
-from openai.resources.chat.completions import ChatCompletionChunk, Stream
from rich.console import Console
from rich.markdown import Markdown
from rich.markup import escape
from rich.table import Table
from rich.text import Text
-from typer.core import TyperGroup
-from typer.models import Context
-from arcade.core.catalog import ToolCatalog
-from arcade.core.client import EngineClient
-from arcade.core.config import Config
-from arcade.core.schema import ToolCallOutput, ToolContext
-from arcade.core.toolkit import Toolkit
+from arcade.cli.authn import check_existing_login, LocalAuthCallbackServer
+from arcade.cli.utils import (
+ OrderCommands,
+ create_cli_catalog,
+ display_streamed_markdown,
+ validate_and_get_config,
+)
+from arcade.client import Arcade
-
-class OrderCommands(TyperGroup):
- def list_commands(self, ctx: Context) -> list[str]: # type: ignore[override]
- """Return list of commands in the order appear."""
- return list(self.commands) # get commands using self.commands
-
-
-console = Console()
cli = typer.Typer(
cls=OrderCommands,
)
+console = Console()
@cli.command(help="Log in to Arcade Cloud")
-def login(
- username: str = typer.Option(..., prompt="Username", help="Your Arcade Cloud username"),
- api_key: str = typer.Option(None, prompt="API Key", help="Your Arcade Cloud API Key"),
-) -> None:
+def login() -> None:
"""
Logs the user into Arcade Cloud.
"""
- # Here you would add the logic to authenticate the user with Arcade Cloud
- raise NotImplementedError("This feature is not yet implemented.")
+
+ if check_existing_login():
+ return
+
+ # Start the HTTP server in a new thread
+ state = str(uuid.uuid4())
+ auth_server = LocalAuthCallbackServer(state)
+ server_thread = threading.Thread(target=auth_server.run_server)
+ server_thread.start()
+
+ try:
+ # Open the browser for user login
+ callback_uri = "http://localhost:9905/callback"
+ params = urlencode({"callback_uri": callback_uri, "state": state})
+ # TODO: make this configurable
+ login_url = f"http://localhost:8001/api/v1/auth/cli_login?{params}"
+ console.print("Opening a browser to log you in...")
+ webbrowser.open(login_url)
+
+ # Wait for the server thread to finish
+ server_thread.join()
+ except KeyboardInterrupt:
+ auth_server.shutdown_server()
+ finally:
+ if server_thread.is_alive():
+ server_thread.join() # Ensure the server thread completes and cleans up
@cli.command(help="Log out of Arcade Cloud")
@@ -48,8 +65,14 @@ def logout() -> None:
"""
Logs the user out of Arcade Cloud.
"""
- # Here you would add the logic to log the user out of Arcade Cloud
- raise NotImplementedError("This feature is not yet implemented.")
+
+ # If ~/.arcade/arcade.toml exists, delete it
+ config_file_path = os.path.expanduser("~/.arcade/arcade.toml")
+ if os.path.exists(config_file_path):
+ os.remove(config_file_path)
+ console.print("You're now logged out.", style="bold")
+ else:
+ console.print("You're not logged in.", style="bold red")
@cli.command(help="Create a new toolkit package directory")
@@ -100,135 +123,21 @@ def show(
console.print(error_message, style="bold red")
-@cli.command(help="Run a tool using an LLM to predict the arguments")
-def run(
- toolkit: Optional[str] = typer.Option(
- None, "-t", "--toolkit", help="The toolkit to include in the run"
- ),
- model: str = typer.Option("gpt-4o", "-m", help="The model to use for prediction."),
- tool: str = typer.Option(None, "--tool", help="The name of the tool to run."),
- choice: str = typer.Option(
- "generate", "-c", "--choice", help="The value of the tool choice argument"
- ),
- stream: bool = typer.Option(
- False, "-s", "--stream", is_flag=True, help="Stream the tool output."
- ),
- prompt: str = typer.Argument(..., help="The prompt to use for context"),
-) -> None:
- """
- Run a tool using an LLM to predict the arguments.
- """
- from arcade.core.client import EngineClient
- from arcade.core.executor import ToolExecutor
-
- try:
- catalog = create_cli_catalog(toolkit=toolkit)
-
- tools = [catalog[tool]] if tool else list(catalog)
-
- config = Config.load_from_file()
- if not config.engine or not config.engine_url:
- console.print("❌ Engine configuration not found or URL is missing.", style="bold red")
- typer.Exit(code=1)
-
- if not config.api or not config.api.key:
- console.print(
- "❌ API configuration not found or key is missing. Please run `arcade login`.",
- style="bold red",
- )
- typer.Exit(code=1)
- client = EngineClient(api_key=config.api.key, base_url=config.engine_url)
-
- # TODO better way of doing this
- tool_choice = "auto" if choice in ["execute", "generate"] else choice
- calls = client.call_tool(tools, tool_choice=tool_choice, prompt=prompt, model=model)
-
- if len(calls) == 0:
- console.print("[bold red]No tools were called[/bold red]")
-
- messages = [
- {"role": "user", "content": prompt},
- ]
-
- for tool_name, parameters in calls:
- called_tool = catalog[tool_name]
- console.print(f"Calling tool: {tool_name} with params: {parameters}", style="bold blue")
-
- # TODO async.gather instead of loop.
- output: ToolCallOutput = asyncio.run(
- ToolExecutor.run(
- called_tool.tool,
- called_tool.definition,
- called_tool.input_model,
- called_tool.output_model,
- ToolContext(),
- **parameters,
- )
- )
- if output.error:
- console.print(output.error.message, style="bold red")
- typer.Exit(code=1)
- else:
- messages += [
- {
- "role": "assistant",
- # TODO: escape the output and ensure serialization works
- "content": f"Results of Tool {tool_name}: {output.value!s}",
- },
- ]
-
- if choice == "execute":
- console.print(output.value, style="green")
- raise typer.Exit(0)
- else:
- if stream:
- stream_response = client.stream_complete(model=model, messages=messages)
- display_streamed_markdown(stream_response)
- else:
- response = client.complete(model=model, messages=messages)
- if not len(response.choices) and not response.choices[0].message.content:
- console.print("No response from the tool.", style="bold red")
- else:
- console.print(Markdown(response.choices[0].message.content or ""))
-
- except RuntimeError as e:
- error_message = f"❌ Failed to run tool{': ' + escape(str(e)) if str(e) else ''}"
- console.print(error_message, style="bold red")
-
-
@cli.command(help="Chat with a language model")
def chat(
model: str = typer.Option("gpt-4o", "-m", help="The model to use for prediction."),
stream: bool = typer.Option(
- True, "-s", "--stream", is_flag=True, help="Stream the tool output."
+ False, "-s", "--stream", is_flag=True, help="Stream the tool output."
),
) -> None:
"""
Chat with a language model.
"""
+ config = validate_and_get_config()
- config = Config.load_from_file()
- if not config.engine or not config.engine_url:
- console.print("❌ Engine configuration not found or URL is missing.", style="bold red")
- typer.Exit(code=1)
-
- if not config.api or not config.api.key:
- console.print(
- "❌ API configuration not found or key is missing. Please run `arcade login`.",
- style="bold red",
- )
- typer.Exit(code=1)
-
- client = EngineClient(api_key=config.api.key, base_url=config.engine_url)
-
- if config.user and config.user.email:
- user_email = config.user.email
- user_attribution = f"({user_email})"
- else:
- console.print(
- "❌ User email not found in configuration. Please run `arcade login`.", style="bold red"
- )
- typer.Exit(code=1)
+ client = Arcade(api_key=config.api.key, base_url=config.engine_url)
+ user_email = config.user.email if config.user else None
+ user_attribution = f"({user_email})" if user_email else ""
try:
# start messages conversation
@@ -237,7 +146,7 @@ def chat(
chat_header = Text.assemble(
"\n",
(
- "======== Arcade AI Chat ========",
+ "=== Arcade AI Chat ===",
"bold magenta underline",
),
"\n",
@@ -251,20 +160,24 @@ def chat(
messages.append({"role": "user", "content": user_input})
if stream:
- stream_response = client.stream_complete(
+ # TODO Fix this in the client so users don't deal with these
+ # typing issues
+ stream_response = client.chat.completions.create( # type: ignore[call-overload]
model=model,
messages=messages,
tool_choice="generate",
user=user_email,
+ stream=True,
)
role, message = display_streamed_markdown(stream_response)
messages.append({"role": role, "content": message})
else:
- response = client.complete(
+ response = client.chat.completions.create( # type: ignore[call-overload]
model=model,
messages=messages,
tool_choice="generate",
user=user_email,
+ stream=False,
)
message_content = response.choices[0].message.content or ""
role = response.choices[0].message.role
@@ -310,30 +223,6 @@ def dev(
raise typer.Exit(code=1)
-@cli.command(help="Manage the Arcade Engine (start/stop/restart)")
-def engine(
- action: str = typer.Argument("start", help="The action to take (start/stop/restart)"),
- host: str = typer.Option("localhost", "--host", "-h", help="The host of the engine"),
- port: int = typer.Option(6901, "--port", "-p", help="The port of the engine"),
-) -> None:
- """
- Manage the Arcade Engine (start/stop/restart)
- """
- raise NotImplementedError("This feature is not yet implemented.")
-
-
-@cli.command(help="Manage credientials stored in the Arcade Engine")
-def credentials(
- action: str = typer.Argument("show", help="The action to take (add/remove/show)"),
- name: str = typer.Option(None, "--name", "-n", help="The name of the credential to add/remove"),
- val: str = typer.Option(None, "--val", "-v", help="The value of the credential to add/remove"),
-) -> None:
- """
- Manage credientials stored in the Arcade Engine
- """
- raise NotImplementedError("This feature is not yet implemented.")
-
-
@cli.command(help="Show/edit configuration details of the Arcade Engine")
def config(
action: str = typer.Argument("show", help="The action to take (show/edit)"),
@@ -345,8 +234,7 @@ def config(
"""
Show/edit configuration details of the Arcade Engine
"""
-
- config = Config.load_from_file()
+ config = validate_and_get_config()
if action == "show":
display_config_as_table(config)
@@ -376,7 +264,7 @@ def config(
raise typer.Exit(code=1)
-def display_config_as_table(config: Config) -> None:
+def display_config_as_table(config) -> None: # type: ignore[no-untyped-def]
"""
Display the configuration details as a table using Rich library.
"""
@@ -399,58 +287,3 @@ def display_config_as_table(config: Config) -> None:
table.add_row("", "", "")
console.print(table)
-
-
-def display_streamed_markdown(stream: Stream[ChatCompletionChunk]) -> tuple[str, str]:
- """
- Display the streamed markdown chunks as a single line.
- """
- from rich.live import Live
-
- full_message = ""
- role = ""
- with Live(console=console, refresh_per_second=10) as live:
- for chunk in stream:
- choice = chunk.choices[0]
- chunk_message = choice.delta.content
- if role == "":
- role = choice.delta.role or ""
- if role == "assistant":
- console.print("\n[bold blue]Assistant:[/bold blue] ")
- if chunk_message:
- full_message += chunk_message
- markdown_chunk = Markdown(full_message)
- live.update(markdown_chunk)
- return role, full_message
-
-
-def create_cli_catalog(
- toolkit: str | None = None,
- show_toolkits: bool = False,
-) -> ToolCatalog:
- """
- Load toolkits from the python environment.
- """
- if toolkit:
- try:
- prefixed_toolkit = "arcade_" + toolkit
- toolkits = [Toolkit.from_package(prefixed_toolkit)]
- except ValueError:
- try: # try without prefix
- toolkits = [Toolkit.from_package(toolkit)]
- except ValueError as e:
- console.print(f"❌ {e}", style="bold red")
- typer.Exit(code=1)
- else:
- toolkits = Toolkit.find_all_arcade_toolkits()
-
- if not toolkits:
- console.print("❌ No toolkits found or specified", style="bold red")
- typer.Exit(code=1)
-
- catalog = ToolCatalog()
- for loaded_toolkit in toolkits:
- if show_toolkits:
- console.print(f"Loading toolkit: {loaded_toolkit.name}", style="bold blue")
- catalog.add_toolkit(loaded_toolkit)
- return catalog
diff --git a/arcade/arcade/cli/new.py b/arcade/arcade/cli/new.py
index 2f4bb0ba..885e9ecd 100644
--- a/arcade/arcade/cli/new.py
+++ b/arcade/arcade/cli/new.py
@@ -13,7 +13,7 @@ console = Console()
DEFAULT_VERSIONS = {
"python": "^3.10",
"arcade-ai": f"^{VERSION}",
- "pytest": "^7.4.0",
+ "pytest": "^8.3.0",
}
diff --git a/arcade/arcade/cli/utils.py b/arcade/arcade/cli/utils.py
new file mode 100644
index 00000000..3fc9343e
--- /dev/null
+++ b/arcade/arcade/cli/utils.py
@@ -0,0 +1,107 @@
+from typing import TYPE_CHECKING
+
+import typer
+from openai.resources.chat.completions import ChatCompletionChunk, Stream
+from rich.console import Console
+from rich.live import Live
+from rich.markdown import Markdown
+from typer.core import TyperGroup
+from typer.models import Context
+
+from arcade.core.catalog import ToolCatalog
+from arcade.core.toolkit import Toolkit
+
+if TYPE_CHECKING:
+ from arcade.core.config import Config
+
+console = Console()
+
+
+class OrderCommands(TyperGroup):
+ def list_commands(self, ctx: Context) -> list[str]: # type: ignore[override]
+ """Return list of commands in the order appear."""
+ return list(self.commands) # get commands using self.commands
+
+
+def create_cli_catalog(
+ toolkit: str | None = None,
+ show_toolkits: bool = False,
+) -> ToolCatalog:
+ """
+ Load toolkits from the python environment.
+ """
+ if toolkit:
+ try:
+ prefixed_toolkit = "arcade_" + toolkit
+ toolkits = [Toolkit.from_package(prefixed_toolkit)]
+ except ValueError:
+ try: # try without prefix
+ toolkits = [Toolkit.from_package(toolkit)]
+ except ValueError as e:
+ console.print(f"❌ {e}", style="bold red")
+ typer.Exit(code=1)
+ else:
+ toolkits = Toolkit.find_all_arcade_toolkits()
+
+ if not toolkits:
+ console.print("❌ No toolkits found or specified", style="bold red")
+ typer.Exit(code=1)
+
+ catalog = ToolCatalog()
+ for loaded_toolkit in toolkits:
+ if show_toolkits:
+ console.print(f"Loading toolkit: {loaded_toolkit.name}", style="bold blue")
+ catalog.add_toolkit(loaded_toolkit)
+ return catalog
+
+
+def display_streamed_markdown(stream: Stream[ChatCompletionChunk]) -> tuple[str, str]:
+ """
+ Display the streamed markdown chunks as a single line.
+ """
+
+ full_message = ""
+ role = ""
+ with Live(console=console, refresh_per_second=10) as live:
+ for chunk in stream:
+ choice = chunk.choices[0]
+ chunk_message = choice.delta.content
+ if role == "":
+ role = choice.delta.role or ""
+ if role == "assistant":
+ console.print("\n[bold blue]Assistant:[/bold blue] ")
+ if chunk_message:
+ full_message += chunk_message
+ markdown_chunk = Markdown(full_message)
+ live.update(markdown_chunk)
+ return role, full_message
+
+
+def validate_and_get_config(
+ validate_engine: bool = True,
+ validate_api: bool = True,
+ validate_user: bool = True,
+) -> "Config":
+ """
+ Validates the configuration, user, and returns the Config object
+ """
+ from arcade.core.config import config
+
+ if validate_engine and (not config.engine or not config.engine_url):
+ console.print("❌ Engine configuration not found or URL is missing.", style="bold red")
+ raise typer.Exit(code=1)
+
+ if validate_api and (not config.api or not config.api.key):
+ console.print(
+ "❌ API configuration not found or key is missing. Please run `arcade login`.",
+ style="bold red",
+ )
+ raise typer.Exit(code=1)
+
+ if validate_user and (not config.user or not config.user.email):
+ console.print(
+ "❌ User email not found in configuration. Please run `arcade login`.", style="bold red"
+ )
+ raise typer.Exit(code=1)
+
+ return config
diff --git a/arcade/arcade/client/base.py b/arcade/arcade/client/base.py
index 33d89e9d..f8270806 100644
--- a/arcade/arcade/client/base.py
+++ b/arcade/arcade/client/base.py
@@ -5,8 +5,6 @@ from urllib.parse import urljoin
import httpx
from httpx import Timeout
-from arcade.core.config import config
-
T = TypeVar("T")
ResponseT = TypeVar("ResponseT")
@@ -26,7 +24,6 @@ class BaseArcadeClient:
base_url: str,
api_key: str | None = None,
headers: dict[str, str] | None = None,
- proxies: str | dict[str, str] | None = None,
timeout: float | Timeout = 10.0,
retries: int = 3,
):
@@ -37,28 +34,20 @@ class BaseArcadeClient:
base_url: The base URL for the Arcade API.
api_key: The API key for authentication.
headers: Additional headers to include in requests.
- proxies: Proxy configuration for requests.
timeout: Request timeout in seconds.
retries: Number of retries for failed requests.
"""
self._base_url = base_url
- self._api_key = api_key or os.environ.get("ARCADE_API_KEY") or config.api.key
+ self._api_key = api_key or os.environ.get("ARCADE_API_KEY")
self._headers = headers or {}
self._headers.setdefault("Authorization", f"Bearer {self._api_key}")
self._headers.setdefault("Content-Type", "application/json")
- self._proxies = proxies
self._timeout = timeout
self._retries = retries
def _build_url(self, path: str) -> str:
"""
Build the full URL for a given path.
-
- Args:
- path: The path to append to the base URL.
-
- Returns:
- The full URL.
"""
return urljoin(self._base_url, path)
@@ -71,7 +60,6 @@ class SyncArcadeClient(BaseArcadeClient):
self._client = httpx.Client(
base_url=self._base_url,
headers=self._headers,
- proxies=self._proxies,
timeout=self._timeout,
)
@@ -116,7 +104,6 @@ class AsyncArcadeClient(BaseArcadeClient):
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers=self._headers,
- proxies=self._proxies,
timeout=self._timeout,
)
return self._client
diff --git a/arcade/arcade/client/client.py b/arcade/arcade/client/client.py
index f831f387..a81c85d4 100644
--- a/arcade/arcade/client/client.py
+++ b/arcade/arcade/client/client.py
@@ -24,11 +24,14 @@ from arcade.core.schema import ToolDefinition
T = TypeVar("T")
ClientT = TypeVar("ClientT", SyncArcadeClient, AsyncArcadeClient)
+API_VERSION = "v1"
+BASE_URL = "https://api.arcade-ai.com"
+
class AuthResource(BaseResource[ClientT]):
"""Authentication resource."""
- _base_path = "/v1/auth"
+ _base_path = f"/{API_VERSION}/auth"
def authorize(
self,
@@ -66,11 +69,9 @@ class AuthResource(BaseResource[ClientT]):
return AuthResponse(**data)
def poll_authorization(self, auth_id: str) -> AuthResponse:
- """
- Poll for the status of an authorization request.
+ """Poll for the status of an authorization
- Args:
- auth_id: The authorization ID.
+ Polls using the authorization ID returned from the authorize method.
Example:
auth_status = client.auth.poll_authorization("auth_123")
@@ -84,7 +85,7 @@ class AuthResource(BaseResource[ClientT]):
class ToolResource(BaseResource[ClientT]):
"""Tool resource."""
- _base_path = "/v1/tools"
+ _base_path = f"/{API_VERSION}/tool"
def run(
self,
@@ -116,10 +117,6 @@ class ToolResource(BaseResource[ClientT]):
def get(self, director_id: str, tool_id: str) -> ToolDefinition:
"""
Get the specification for a tool.
-
- Args:
- director_id: The director ID.
- tool_id: The tool ID.
"""
data = self._client._execute_request( # type: ignore[attr-defined]
"GET",
@@ -132,12 +129,10 @@ class ToolResource(BaseResource[ClientT]):
class ArcadeClientMixin(Generic[ClientT]):
"""Mixin for Arcade clients."""
- def __init__(self, base_url: str, *args: Any, **kwargs: Any):
- super().__init__(base_url, *args, **kwargs)
- self._openai_client: OpenAI | AsyncOpenAI | None = None
+ def __init__(self, base_url: str = BASE_URL, *args: Any, **kwargs: Any):
+ super().__init__(base_url, *args, **kwargs) # type: ignore[call-arg]
self.auth: AuthResource = AuthResource(self)
self.tool: ToolResource = ToolResource(self)
- self.chat: Chat | AsyncChat | None = None
def _handle_http_error(
self,
@@ -148,25 +143,39 @@ class ArcadeClientMixin(Generic[ClientT]):
error_class = error_map.get(status_code, InternalServerError)
raise error_class(str(e), response=e.response)
+ def _chat_url(self, base_url: str) -> str:
+ # TODO (sam): make chat a Resource like others but maintain
+ # the ability to call chat directly like the openai clients
+ chat_url = str(base_url)
+ if not base_url.endswith(API_VERSION):
+ chat_url = f"{base_url}/{API_VERSION}"
+ return chat_url
+
class Arcade(ArcadeClientMixin[SyncArcadeClient], SyncArcadeClient):
- """Synchronous Arcade client."""
+ """Synchronous Arcade client.
+
+ Example:
+ from arcade.client import Arcade
+
+ client = Arcade(api_key="your-api-key")
+ client.auth.authorize(...)
+ client.tool.run(...)
+ """
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
- # Assume we are using the LLM API of the Engine for now
- self._openai_client = OpenAI(base_url=self._base_url + "/v1", api_key=self._api_key)
- self.chat = self._openai_client.chat
+ chat_url = self._chat_url(self._base_url)
+ self._openai_client = OpenAI(base_url=chat_url, api_key=self._api_key)
+
+ @property
+ def chat(self) -> Chat:
+ return self._openai_client.chat
def _execute_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""
Execute a synchronous request.
-
- Args:
- method: The HTTP method.
- url: The URL to request.
- **kwargs: Additional arguments for the request.
"""
try:
response = self._request(method, url, **kwargs)
@@ -189,17 +198,17 @@ class AsyncArcade(ArcadeClientMixin[AsyncArcadeClient], AsyncArcadeClient):
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
- self._openai_client = AsyncOpenAI(base_url=self._base_url + "/v1")
- self.chat = self._openai_client.chat
+
+ chat_url = self._chat_url(self._base_url)
+ self._openai_client = AsyncOpenAI(base_url=chat_url, api_key=self._api_key)
+
+ @property
+ def chat(self) -> AsyncChat:
+ return self._openai_client.chat
async def _execute_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""
Execute an asynchronous request.
-
- Args:
- method: The HTTP method.
- url: The URL to request.
- **kwargs: Additional arguments for the request.
"""
try:
response = await self._request(method, url, **kwargs)
diff --git a/arcade/arcade/client/schema.py b/arcade/arcade/client/schema.py
index 1d0e03a0..881b1425 100644
--- a/arcade/arcade/client/schema.py
+++ b/arcade/arcade/client/schema.py
@@ -24,9 +24,10 @@ class AuthProvider(str, Enum):
class AuthRequest(BaseModel):
"""
The requirements for authorization for a tool
+ # TODO (Nate): Make a validator here
"""
- authority: AnyUrl | None = None
+ authority: AnyUrl | str | None = None
"""The URL of the OAuth 2.0 authorization server."""
scope: list[str]
diff --git a/arcade/arcade/core/client.py b/arcade/arcade/core/client.py
deleted file mode 100644
index 6cd04576..00000000
--- a/arcade/arcade/core/client.py
+++ /dev/null
@@ -1,187 +0,0 @@
-import json
-from enum import Enum
-from typing import Any, Optional
-
-from openai import OpenAI
-from openai.resources.chat.completions import ChatCompletion, ChatCompletionChunk, Stream
-from pydantic import BaseModel
-from pydantic_core import PydanticUndefined
-
-from arcade.core.catalog import MaterializedTool
-
-PYTHON_TO_JSON_TYPES: dict[type, str] = {
- str: "string",
- int: "integer",
- float: "number",
- bool: "boolean",
- list: "array",
- dict: "object",
-}
-
-ToolCalls = dict[str, dict[str, Any]]
-
-
-def python_type_to_json_type(python_type: type[Any]) -> dict[str, Any] | str:
- """
- Map Python types to JSON Schema types, including handling of
- complex types such as lists and dictionaries.
- """
- if hasattr(python_type, "__origin__"):
- origin = python_type.__origin__
-
- if origin is list:
- item_type = python_type_to_json_type(python_type.__args__[0])
- return {"type": "array", "items": item_type}
- elif origin is dict:
- value_type = python_type_to_json_type(python_type.__args__[1])
- return {"type": "object", "additionalProperties": value_type}
-
- elif issubclass(python_type, BaseModel):
- return model_to_json_schema(python_type)
-
- return PYTHON_TO_JSON_TYPES.get(python_type, "string")
-
-
-def model_to_json_schema(model: type[BaseModel]) -> dict[str, Any]:
- """
- Convert a Pydantic model to a JSON schema.
- """
- properties = {}
- required = []
- for field_name, model_field in model.model_fields.items():
- type_json = python_type_to_json_type(model_field.annotation) # type: ignore[arg-type]
- if isinstance(type_json, dict):
- field_schema = type_json
- else:
- field_schema = {
- "type": type_json,
- "description": model_field.description or "",
- }
- if model_field.default not in [None, PydanticUndefined]:
- if isinstance(model_field.default, Enum):
- field_schema["default"] = model_field.default.value
- else:
- field_schema["default"] = model_field.default
- if model_field.is_required():
- required.append(field_name)
- properties[field_name] = field_schema
- return {
- "type": "object",
- "properties": properties,
- "required": required,
- }
-
-
-def schema_to_openai_tool(tool: MaterializedTool) -> dict[str, Any]:
- """
- Convert a ToolDefinition object to a JSON schema dictionary in the specified function format.
- """
- input_model_schema = model_to_json_schema(tool.input_model)
- function_schema = {
- "type": "function",
- "function": {
- "name": tool.definition.name,
- "description": tool.definition.description,
- "parameters": input_model_schema,
- },
- }
- return function_schema
-
-
-def called_tool(chat_completion: ChatCompletion) -> bool:
- """
- Return true if the chat completion called a tool.
- """
- choice = chat_completion.choices[0]
- if choice.message.tool_calls:
- return True
- return False
-
-
-def get_tool_args(chat_completion: ChatCompletion) -> list[tuple[str, dict[str, Any]]]:
- """
- Returns the tool arguments from the chat completion object.
- """
- tool_args_list = []
- message = chat_completion.choices[0].message
- if message.tool_calls:
- for tool_call in message.tool_calls:
- tool_args_list.append(
- (
- tool_call.function.name,
- json.loads(tool_call.function.arguments),
- )
- )
- return tool_args_list
-
-
-class EngineClient:
- def __init__(self, api_key: str, base_url: str | None = None):
- self.client = OpenAI(api_key=api_key, base_url=base_url)
-
- def __getattr__(self, name: str) -> Any:
- return getattr(self.client, name)
-
- def call_tool(
- self,
- tools: list[MaterializedTool],
- model: str,
- messages: Optional[list[dict[str, Any]]] = None,
- tool_choice: Optional[str] = "required",
- parallel_tool_calls: Optional[bool] = True,
- prompt: Optional[str] = "",
- **kwargs: Any,
- ) -> list[tuple[str, dict[str, Any]]]:
- """
- Infer the arguments for a given tool and call the OpenAI API.
- """
- specs = [schema_to_openai_tool(tool) for tool in tools]
-
- if messages is None:
- messages = [{"role": "user", "content": prompt}]
- try:
- completion = self.complete(
- model=model,
- messages=messages,
- tools=specs,
- tool_choice=tool_choice,
- parallel_tool_calls=parallel_tool_calls,
- **kwargs,
- )
- if not called_tool(completion):
- raise ValueError("No tool call was made.")
-
- except (KeyError, IndexError) as e:
- raise ValueError("Invalid response format from OpenAI API.") from e
-
- return get_tool_args(completion)
-
- def complete(
- self,
- model: str,
- messages: list[dict[str, Any]],
- **kwargs: Any,
- ) -> ChatCompletion:
- """
- Call the OpenAI API with the given messages.
- """
- completion = self.client.chat.completions.create(
- model=model,
- messages=messages, # type: ignore[arg-type]
- **kwargs,
- )
- return completion
-
- def stream_complete( # type: ignore[misc]
- self,
- model: str,
- messages: list[dict[str, Any]],
- **kwargs: Any,
- ) -> Stream[ChatCompletionChunk]:
- stream = self.client.chat.completions.create(
- model=model,
- messages=messages, # type: ignore[arg-type]
- stream=True,
- **kwargs,
- )
- yield from stream
diff --git a/arcade/arcade/core/config.py b/arcade/arcade/core/config.py
index 885078ea..d0342c3a 100644
--- a/arcade/arcade/core/config.py
+++ b/arcade/arcade/core/config.py
@@ -1,5 +1,9 @@
+import ipaddress
+from functools import cached_property, lru_cache
from pathlib import Path
+from urllib.parse import urlparse
+import idna
import toml
from pydantic import BaseModel, ValidationError
@@ -33,15 +37,15 @@ class EngineConfig(BaseModel):
Arcade Engine configuration.
"""
- host: str = "localhost"
+ host: str = "api.arcade-ai.com"
"""
Arcade Engine host.
"""
- port: int = 6901
+ port: int | None = None
"""
Arcade Engine port.
"""
- tls: bool = False
+ tls: bool = True
"""
Whether to use TLS for the connection to Arcade Engine.
"""
@@ -60,7 +64,7 @@ class Config(BaseModel):
"""
Arcade user configuration.
"""
- engine: EngineConfig | None = None
+ engine: EngineConfig | None = EngineConfig()
"""
Arcade Engine configuration.
"""
@@ -79,15 +83,77 @@ class Config(BaseModel):
"""
return cls.get_config_dir_path() / "arcade.toml"
- @property
+ @cached_property
def engine_url(self) -> str:
"""
- Get the URL of the Arcade Engine.
+ Get the cached URL of the Arcade Engine.
+
+ This property is cached after its first access to improve performance.
+ The cache is automatically invalidated if any of the underlying data changes.
+
+ The port is included in the URL unless the host is a fully qualified domain name
+ (excluding IP addresses) and no port is specified. Handles IPv4, IPv6, IDNs, and
+ hostnames with underscores.
+
+ This property exists to provide a consistent and correctly formatted URL for
+ connecting to the Arcade Engine, taking into account various configuration
+ options and edge cases. It ensures that:
+
+ 1. The correct protocol (http/https) is used based on the TLS setting.
+ 2. IPv4 and IPv6 addresses are properly formatted.
+ 3. Internationalized Domain Names (IDNs) are correctly encoded.
+ 4. Fully Qualified Domain Names (FQDNs) are identified and handled appropriately.
+ 5. Ports are included when necessary, respecting common conventions for FQDNs.
+ 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.
+
+ Raises:
+ ValueError: If the engine configuration is missing or incomplete.
"""
if self.engine is None:
- raise ValueError("Engine not set")
+ raise ValueError("Configuration for Engine is not set in arcade.toml")
+ if not self.engine.host:
+ raise ValueError("Configuration for Engine host is not set in arcade.toml")
+
protocol = "https" if self.engine.tls else "http"
- return f"{protocol}://{self.engine.host}:{self.engine.port}/v1"
+
+ # Handle potential IDNs
+ try:
+ encoded_host = idna.encode(self.engine.host).decode("ascii")
+ except idna.IDNAError:
+ encoded_host = self.engine.host
+
+ # Check if the host is a valid IP address (IPv4 or IPv6)
+ try:
+ ipaddress.ip_address(encoded_host)
+ is_ip = True
+ except ValueError:
+ is_ip = False
+
+ # Parse the host, handling potential IPv6 addresses
+ host_for_parsing = f"[{encoded_host}]" if is_ip and ":" in encoded_host else encoded_host
+ parsed_host = urlparse(f"//{host_for_parsing}")
+
+ # Check if the host is a fully qualified domain name (excluding IP addresses)
+ is_fqdn = "." in parsed_host.netloc and not is_ip and "_" not in parsed_host.netloc
+
+ # Handle hosts that might already include a port
+ 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}/v1"
+
+ if is_fqdn and self.engine.port is None:
+ return f"{protocol}://{encoded_host}/v1"
+ elif self.engine.port is not None:
+ return f"{protocol}://{encoded_host}:{self.engine.port}/v1"
+ else:
+ return f"{protocol}://{encoded_host}/v1"
@classmethod
def ensure_config_dir_exists(cls) -> None:
@@ -102,7 +168,22 @@ class Config(BaseModel):
def load_from_file(cls) -> "Config":
"""
Load the configuration from the TOML file in the configuration directory.
- If no configuration file exists, create a new one with default values.
+
+ If no configuration file exists, this method will create a new one with default values.
+ The default configuration includes:
+ - An empty API configuration
+ - A default Engine configuration (host: "api.arcade-ai.com", port: None, tls: True)
+ - No user configuration
+
+ This behavior ensures that the application always has a valid configuration to work with,
+ but it may not be suitable for all use cases. If a specific configuration is required,
+ ensure that the configuration file exists before calling this method.
+
+ Returns:
+ Config: The loaded or newly created configuration.
+
+ Raises:
+ ValueError: If the existing configuration file is invalid.
"""
cls.ensure_config_dir_exists()
@@ -149,5 +230,21 @@ class Config(BaseModel):
config_file_path.write_text(toml.dumps(self.model_dump()))
-# Singleton instance of Config
-config = Config.load_from_file()
+@lru_cache(maxsize=1)
+def get_config() -> Config:
+ """
+ Get the Arcade configuration.
+
+ This function is cached, so subsequent calls will return the same Config object
+ without reloading from the file, unless the cache is cleared.
+
+ remember to clear the cache if the configuration file is modified.
+ use `get_config.cache_clear()` to clear the cache.
+
+ Returns:
+ Config: The Arcade configuration.
+ """
+ return Config.load_from_file()
+
+
+config = get_config()
diff --git a/examples/fastapi/arcade_example_fastapi/main.py b/examples/fastapi/arcade_example_fastapi/main.py
index d1c6d746..ee871565 100644
--- a/examples/fastapi/arcade_example_fastapi/main.py
+++ b/examples/fastapi/arcade_example_fastapi/main.py
@@ -1,21 +1,17 @@
+from arcade_github.tools import repo, user
+from arcade_gmail.tools import gmail
+from arcade_slack.tools import chat
from fastapi import FastAPI, HTTPException
-from openai import AsyncOpenAI
from pydantic import BaseModel
-from arcade_gmail.tools import gmail
-from arcade_github.tools import repo, user
-from arcade_slack.tools import chat
-from arcade.core.config import config
-
from arcade.actor.fastapi.actor import FastAPIActor
+from arcade.client import AsyncArcade
+from arcade.core.config import config
if not config.api or not config.api.key:
raise ValueError("Arcade API key not set. Please run `arcade login`.")
-client = AsyncOpenAI(
- api_key=config.api.key,
- base_url="http://localhost:9099/v1",
-)
+client = AsyncArcade(api_key=config.api.key)
app = FastAPI()
diff --git a/examples/langchain/gmail.py b/examples/langchain/gmail.py
index 538904f8..cf90d05d 100644
--- a/examples/langchain/gmail.py
+++ b/examples/langchain/gmail.py
@@ -30,7 +30,7 @@ from langgraph.prebuilt import create_react_agent
# Step 3 (Option 2) Use the Arcade SDK to authenticate with Gmail
from arcade.client import Arcade, AuthProvider
-client = Arcade(base_url="http://localhost:9099", api_key=os.environ["ARCADE_API_KEY"])
+client = Arcade(api_key=os.environ["ARCADE_API_KEY"])
challenge = client.auth.authorize(
provider=AuthProvider.google,
diff --git a/toolkits/gmail/arcade_gmail/tools/gdrive.py b/toolkits/gmail/arcade_gmail/tools/gdrive.py
deleted file mode 100644
index 4e7f8490..00000000
--- a/toolkits/gmail/arcade_gmail/tools/gdrive.py
+++ /dev/null
@@ -1,65 +0,0 @@
-import os
-
-from google.auth.transport.requests import Request
-from google.oauth2.credentials import Credentials
-from google_auth_oauthlib.flow import InstalledAppFlow
-from google.auth.exceptions import RefreshError
-from googleapiclient.discovery import build
-from typing import Annotated
-from arcade.sdk import tool
-
-SECRET_FILE = "/Users/spartee/Dropbox/Arcade/gcp/credentials.json"
-DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive.metadata.readonly"]
-
-
-@tool
-async def list_drive_files(
- n_files: Annotated[int, "Number of files to search"] = 5,
-) -> list[str]:
- """List files from a Google Drive account and return their details."""
-
- creds = None
- # The file token.json stores the user's access and refresh tokens, and is
- # created automatically when the authorization flow completes for the first time.
- # TODO: use context.authorization.token like gmail.py
- if os.path.exists("token.json"):
- creds = Credentials.from_authorized_user_file("token.json")
- # If there are no (valid) credentials available, let the user log in.
- if not creds or not creds.valid:
- if creds and creds.expired and creds.refresh_token:
- try:
- creds.refresh(Request())
- except RefreshError:
- flow = InstalledAppFlow.from_client_secrets_file(
- SECRET_FILE, DRIVE_SCOPES
- )
- creds = flow.run_local_server(port=0)
- # Save the credentials for the next run
- with open("token.json", "w") as token:
- token.write(creds.to_json())
- else:
- flow = InstalledAppFlow.from_client_secrets_file(SECRET_FILE, DRIVE_SCOPES)
- creds = flow.run_local_server(port=0)
- # Save the credentials for the next run
- with open("token.json", "w") as token:
- token.write(creds.to_json())
-
- # Call the Drive v3 API
- service = build("drive", "v3", credentials=creds)
-
- # Request a list of all the files
- results = (
- service.files()
- .list(pageSize=n_files, fields="nextPageToken, files(id, name)")
- .execute()
- )
- items = results.get("files", [])
-
- if not items:
- print("No files found.")
- else:
- print("Files:")
- for item in items:
- print("{0} ({1})".format(item["name"], item["id"]))
-
- return items