Config Refactor (#116)

# PR Description
1. Removes `arcade config` CLI command and it's helper function.
2. Upon `arcade login`, if the user does not have an `arcade.env` file,
then a templated environment file is created for the user.
3. Removed `EngineConfig` and all references to it. Since there is no
longer an `EngineConfig`, this PR refactors the CLI to compute the
engine URL based on the command-line flags that were provided.
4. Renamed `arcade.toml` to `credentials.yaml`. If a user is using
`arcade.toml`, then we will display a deprecation message and then
automatically migrate their `arcade.toml` to `credentials.yaml`. NOTE:
Eventually this auto-migration support should be removed.
5. `arcade.env` is now an optional file
6. Make `arcade show` default to `https://api.arcade-ai.com/v1` instead
of localhost.
-------




## Ensuring engine url is still computed correctly:
I used the following matrix to ensure that the behavior has not changed
after the refactor. This matrix is tested in `test_utils.py`

DEFAULT_HOST = "api.arcade-ai.com"  
DEFAULT_PORT = None  
DEFAULT_FORCE_TLS = False  
DEFAULT_FORCE_NO_TLS = False  


| Command Line Arguments | Host | Port | Force TLS | Force No TLS |
Main's URL | This PR's URL |

|----------------------------------------|-----------------|---------------|-----------|--------------|-----------------------------------|-----------------------------------|
| | DEFAULT_HOST | DEFAULT_PORT | False | False |
https://api.arcade-ai.com/v1 | https://api.arcade-ai.com/v1 |
| --host localhost | localhost | DEFAULT_PORT | False | False |
http://localhost:9099/v1 | http://localhost:9099/v1 |
| -p 9099 | DEFAULT_HOST | 9099 | False | False |
https://api.arcade-ai.com:9099/v1 | https://api.arcade-ai.com:9099/v1 |
| --host localhost -p 9099 | localhost | 9099 | False | False |
http://localhost:9099/v1 | http://localhost:9099/v1 |
| --tls | DEFAULT_HOST | DEFAULT_PORT | True | False |
https://api.arcade-ai.com/v1 | https://api.arcade-ai.com/v1 |
| --host localhost --tls | localhost | DEFAULT_PORT | True | False |
https://localhost:9099/v1 | https://localhost:9099/v1 |
| -p 9099 --tls | DEFAULT_HOST | 9099 | True | False |
https://api.arcade-ai.com:9099/v1 | https://api.arcade-ai.com:9099/v1 |
| --host localhost -p 9099 --tls | localhost | 9099 | True | False |
https://localhost:9099/v1 | https://localhost:9099/v1 |
| --no-tls | DEFAULT_HOST | DEFAULT_PORT | False | True |
http://api.arcade-ai.com/v1 | http://api.arcade-ai.com/v1 |
| --host localhost --no-tls | localhost | DEFAULT_PORT | False | True |
http://localhost:9099/v1 | http://localhost:9099/v1 |
| -p 9099 --no-tls | DEFAULT_HOST | 9099 | False | True |
http://api.arcade-ai.com:9099/v1 | http://api.arcade-ai.com:9099/v1 |
| --host localhost -p 9099 --no-tls | localhost | 9099 | False | True |
http://localhost:9099/v1 | http://localhost:9099/v1 |
| --tls --no-tls | DEFAULT_HOST | DEFAULT_PORT | True | True |
http://api.arcade-ai.com/v1 | http://api.arcade-ai.com/v1 |
| --host localhost --tls --no-tls | localhost | DEFAULT_PORT | True |
True | http://localhost:9099/v1 | http://localhost:9099/v1 |
| -p 9099 --tls --no-tls | DEFAULT_HOST | 9099 | True | True |
http://api.arcade-ai.com:9099/v1 | http://api.arcade-ai.com:9099/v1 |
| --host localhost -p 9099 --tls --no-tls| localhost | 9099 | True |
True | http://localhost:9099/v1 | http://localhost:9099/v1 |
| --host arandomhost.com | arandomhost.com | DEFAULT_PORT | False |
False | https://arandomhost.com/v1 | https://arandomhost.com/v1 |
This commit is contained in:
Eric Gustin 2024-10-24 11:34:33 -07:00 committed by GitHub
parent 10030c6a12
commit 8508a28f54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 451 additions and 380 deletions

6
.gitignore vendored
View file

@ -1,6 +1,8 @@
.DS_Store .DS_Store
arcade.toml arcade.toml # Deprecated in favor of credentials.yaml
docker/arcade.toml credentials.yaml
docker/arcade.toml # Deprecated in favor of credentials.yaml
docker/credentials.yaml
*.lock *.lock

View file

@ -4,10 +4,11 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any from typing import Any
from urllib.parse import parse_qs from urllib.parse import parse_qs
import toml import yaml
from rich.console import Console from rich.console import Console
from arcade.cli.constants import LOGIN_FAILED_HTML, LOGIN_SUCCESS_HTML from arcade.cli.constants import LOGIN_FAILED_HTML, LOGIN_SUCCESS_HTML
from arcade.cli.utils import create_new_env_file, is_config_file_deprecated
console = Console() console = Console()
@ -61,10 +62,10 @@ class LoginCallbackHandler(BaseHTTPRequestHandler):
os.makedirs(os.path.expanduser("~/.arcade"), exist_ok=True) os.makedirs(os.path.expanduser("~/.arcade"), exist_ok=True)
# TODO don't overwrite existing config # TODO don't overwrite existing config
config_file_path = os.path.expanduser("~/.arcade/arcade.toml") config_file_path = os.path.expanduser("~/.arcade/credentials.yaml")
new_config = {"api": {"key": api_key}, "user": {"email": email}} new_config = {"cloud": {"api": {"key": api_key}, "user": {"email": email}}}
with open(config_file_path, "w") as f: with open(config_file_path, "w") as f:
toml.dump(new_config, f) yaml.dump(new_config, f)
# Send a success response to the browser # Send a success response to the browser
console.print( console.print(
@ -115,26 +116,37 @@ def check_existing_login() -> bool:
Check if the user is already logged in by verifying the config file. Check if the user is already logged in by verifying the config file.
Returns: Returns:
bool: True if the user is already logged in, False otherwise. bool: True if the user is already logged in or is using the deprecated config file, False otherwise.
""" """
config_file_path = os.path.expanduser("~/.arcade/arcade.toml") if is_config_file_deprecated():
return True
# Create a new env file if one doesn't already exist
create_new_env_file()
config_file_path = os.path.expanduser("~/.arcade/credentials.yaml")
if not os.path.exists(config_file_path): if not os.path.exists(config_file_path):
return False return False
try: if os.path.exists(config_file_path):
config: dict[str, Any] = toml.load(config_file_path) try:
api_key = config.get("api", {}).get("key") with open(config_file_path) as f:
email = config.get("user", {}).get("email") config: dict[str, Any] = yaml.safe_load(f)
api_key = config.get("api", {}).get("key")
email = config.get("user", {}).get("email")
if api_key and 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 yaml.YAMLError:
console.print( console.print(
f"You're already logged in as {email}. " f"Error: Invalid configuration file at {config_file_path}", style="bold red"
f"Delete {config_file_path} to log in as a different user."
) )
return True except Exception as e:
except toml.TomlDecodeError: console.print(f"Error: Unable to read configuration file: {e!s}", style="bold red")
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 return True

View file

@ -1,3 +1,6 @@
DEFAULT_CLOUD_HOST = "cloud.arcade-ai.com"
DEFAULT_ENGINE_HOST = "api.arcade-ai.com"
_style_block = b""" _style_block = b"""
<link rel="icon" href="https://cdn.arcade-ai.com/favicons/favicon.ico" sizes="any"> <link rel="icon" href="https://cdn.arcade-ai.com/favicons/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="https://cdn.arcade-ai.com/favicons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="https://cdn.arcade-ai.com/favicons/apple-touch-icon.png">

View file

@ -5,7 +5,6 @@ from rich.panel import Panel
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from arcade.core.config_model import Config
from arcade.core.schema import ToolDefinition from arcade.core.schema import ToolDefinition
if TYPE_CHECKING: if TYPE_CHECKING:
@ -220,7 +219,7 @@ def _format_evaluation(evaluation: "EvaluationResult") -> str:
return "\n".join(result_lines) return "\n".join(result_lines)
def display_arcade_chat_header(config: Config, stream: bool) -> None: def display_arcade_chat_header(base_url: str, stream: bool) -> None:
chat_header = Text.assemble( chat_header = Text.assemble(
"\n", "\n",
( (
@ -231,35 +230,10 @@ def display_arcade_chat_header(config: Config, stream: bool) -> None:
"\n", "\n",
"Chatting with Arcade Engine at ", "Chatting with Arcade Engine at ",
( (
config.engine_url, base_url,
"bold blue", "bold blue",
), ),
) )
if stream: if stream:
chat_header.append(" (streaming)") chat_header.append(" (streaming)")
console.print(chat_header) console.print(chat_header)
def display_config_as_table(config) -> None: # type: ignore[no-untyped-def]
"""
Display the configuration details as a table using Rich library.
"""
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Section")
table.add_column("Name")
table.add_column("Value")
for section_name in config.model_dump():
section = getattr(config, section_name)
if section:
section = section.dict()
first = True
for name, value in section.items():
if first:
table.add_row(section_name, name, str(value))
first = False
else:
table.add_row("", name, str(value))
table.add_row("", "", "")
console.print(table)

View file

@ -52,7 +52,7 @@ def start_servers(
engine_config = _get_config_file(engine_config, default_filename="engine.yaml") engine_config = _get_config_file(engine_config, default_filename="engine.yaml")
# Ensure engine_env is provided or found and either way, validated # Ensure engine_env is provided or found and either way, validated
env_file = _get_config_file(engine_env, default_filename="arcade.env") env_file = _get_config_file(engine_env, default_filename="arcade.env", optional=True)
# Prepare command-line arguments for the actor server and engine # Prepare command-line arguments for the actor server and engine
actor_cmd = _build_actor_command(host, port, debug) actor_cmd = _build_actor_command(host, port, debug)
@ -107,19 +107,22 @@ def _validate_port(port: int) -> int:
return port return port
def _get_config_file(file_path: str | None, default_filename: str = "engine.yaml") -> str: def _get_config_file(
file_path: str | None, default_filename: str = "engine.yaml", optional: bool = False
) -> str | None:
""" """
Determines and validates the config file path. Determines and validates the config file path.
Args: Args:
file_path: Optional path provided by the user. file_path: Optional path provided by the user.
default_filename: The default filename to look for. default_filename: The default filename to look for.
optional: Whether the config file is optional.
Returns: Returns:
The resolved config file path. The resolved config file path. None if the file is optional and not found.
Raises: Raises:
RuntimeError: If the config file is not found. RuntimeError: If the config file is not found and is not optional.
""" """
if file_path: if file_path:
config_path = Path(os.path.expanduser(file_path)).resolve() config_path = Path(os.path.expanduser(file_path)).resolve()
@ -147,8 +150,19 @@ def _get_config_file(file_path: str | None, default_filename: str = "engine.yaml
console.print(f"Using config file at {etc_path}", style="bold green") console.print(f"Using config file at {etc_path}", style="bold green")
return str(etc_path) return str(etc_path)
if optional:
console.print(
f"⚠️ Optional config file '{default_filename}' not found in either of the default locations: "
f"1) current working directory: {Path.cwd() / default_filename}, or "
f"2) user's home directory: {Path.home() / '.arcade' / default_filename}.",
style="bold yellow",
)
return None
console.print( console.print(
f"❌ Config file '{default_filename}' not found in any of the default locations.", f"❌ Config file '{default_filename}' not found in any of the default locations: "
f"1) current working directory: {Path.cwd() / default_filename}, or "
f"2) user's home directory: {Path.home() / '.arcade' / default_filename}.",
style="bold red", style="bold red",
) )
raise RuntimeError(f"Config file '{default_filename}' not found.") raise RuntimeError(f"Config file '{default_filename}' not found.")
@ -187,7 +201,7 @@ def _build_actor_command(host: str, port: int, debug: bool) -> list[str]:
return cmd return cmd
def _build_engine_command(engine_config: str, engine_env: str | None = None) -> list[str]: def _build_engine_command(engine_config: str | None, engine_env: str | None = None) -> list[str]:
""" """
Builds the command to start the engine. Builds the command to start the engine.
@ -198,6 +212,11 @@ def _build_engine_command(engine_config: str, engine_env: str | None = None) ->
Returns: Returns:
The command as a list. The command as a list.
""" """
# This should never happen, but we'll check regardless
if not engine_config:
console.print("❌ Engine configuration file not found", style="bold red")
sys.exit(1)
engine_bin = shutil.which("arcade-engine") engine_bin = shutil.which("arcade-engine")
if not engine_bin: if not engine_bin:
console.print( console.print(

View file

@ -16,9 +16,9 @@ from rich.markup import escape
from rich.text import Text from rich.text import Text
from arcade.cli.authn import LocalAuthCallbackServer, check_existing_login from arcade.cli.authn import LocalAuthCallbackServer, check_existing_login
from arcade.cli.constants import DEFAULT_CLOUD_HOST, DEFAULT_ENGINE_HOST
from arcade.cli.display import ( from arcade.cli.display import (
display_arcade_chat_header, display_arcade_chat_header,
display_config_as_table,
display_eval_results, display_eval_results,
display_tool_details, display_tool_details,
display_tool_messages, display_tool_messages,
@ -27,8 +27,9 @@ from arcade.cli.display import (
from arcade.cli.launcher import start_servers from arcade.cli.launcher import start_servers
from arcade.cli.utils import ( from arcade.cli.utils import (
OrderCommands, OrderCommands,
compute_base_url,
create_cli_catalog, create_cli_catalog,
get_config_with_overrides, delete_deprecated_config_file,
get_eval_files, get_eval_files,
get_tools_from_engine, get_tools_from_engine,
handle_chat_interaction, handle_chat_interaction,
@ -53,7 +54,7 @@ console = Console()
@cli.command(help="Log in to Arcade Cloud", rich_help_panel="User") @cli.command(help="Log in to Arcade Cloud", rich_help_panel="User")
def login( def login(
host: str = typer.Option( host: str = typer.Option(
"cloud.arcade-ai.com", DEFAULT_CLOUD_HOST,
"-h", "-h",
"--host", "--host",
help="The Arcade Cloud host to log in to.", help="The Arcade Cloud host to log in to.",
@ -99,9 +100,10 @@ def logout() -> None:
""" """
Logs the user out of Arcade Cloud. Logs the user out of Arcade Cloud.
""" """
delete_deprecated_config_file()
# If ~/.arcade/arcade.toml exists, delete it # If ~/.arcade/credentials.yaml exists, delete it
config_file_path = os.path.expanduser("~/.arcade/arcade.toml") config_file_path = os.path.expanduser("~/.arcade/credentials.yaml")
if os.path.exists(config_file_path): if os.path.exists(config_file_path):
os.remove(config_file_path) os.remove(config_file_path)
console.print("You're now logged out.", style="bold") console.print("You're now logged out.", style="bold")
@ -136,7 +138,7 @@ def show(
tool: Optional[str] = typer.Option( tool: Optional[str] = typer.Option(
None, "-t", "--tool", help="The specific tool to show details for" None, "-t", "--tool", help="The specific tool to show details for"
), ),
host: Optional[str] = typer.Option( host: str = typer.Option(
None, None,
"-h", "-h",
"--host", "--host",
@ -207,7 +209,7 @@ def chat(
prompt: str = typer.Option(None, "--prompt", help="The system prompt to use for the chat."), prompt: str = typer.Option(None, "--prompt", help="The system prompt to use for the chat."),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
host: str = typer.Option( host: str = typer.Option(
None, DEFAULT_ENGINE_HOST,
"-h", "-h",
"--host", "--host",
help="The Arcade Engine address to send chat requests to.", help="The Arcade Engine address to send chat requests to.",
@ -232,9 +234,10 @@ def chat(
""" """
Chat with a language model. Chat with a language model.
""" """
config = get_config_with_overrides(force_tls, force_no_tls, host, port) config = validate_and_get_config()
base_url = compute_base_url(force_tls, force_no_tls, host, port)
client = Arcade(api_key=config.api.key, base_url=config.engine_url) client = Arcade(api_key=config.api.key, base_url=base_url)
user_email = config.user.email if config.user else None user_email = config.user.email if config.user else None
try: try:
@ -244,7 +247,7 @@ def chat(
if prompt: if prompt:
history.append({"role": "system", "content": prompt}) history.append({"role": "system", "content": prompt})
display_arcade_chat_header(config, stream) display_arcade_chat_header(base_url, stream)
# Try to hit /health endpoint on engine and warn if it is down # Try to hit /health endpoint on engine and warn if it is down
log_engine_health(client) log_engine_health(client)
@ -262,7 +265,7 @@ def chat(
try: try:
# TODO fixup configuration to remove this + "/v1" workaround # TODO fixup configuration to remove this + "/v1" workaround
openai_client = OpenAI(api_key=config.api.key, base_url=config.engine_url + "/v1") openai_client = OpenAI(api_key=config.api.key, base_url=base_url + "/v1")
chat_result = handle_chat_interaction( chat_result = handle_chat_interaction(
openai_client, model, history, user_email, stream openai_client, model, history, user_email, stream
) )
@ -301,48 +304,6 @@ def chat(
raise typer.Exit() raise typer.Exit()
@cli.command(help="Show/edit the local Arcade configuration", rich_help_panel="User")
def config(
action: str = typer.Argument("show", help="The action to take (show/edit)"),
key: str = typer.Option(
None, "--key", "-k", help="The configuration key to edit (e.g., 'api.key')"
),
val: str = typer.Option(None, "--val", "-v", help="The value of the configuration to edit"),
) -> None:
"""
Show/edit configuration details of the Arcade Engine
"""
config = validate_and_get_config()
if action == "show":
display_config_as_table(config)
elif action == "edit":
if not key or val is None:
console.print("❌ Key and value must be provided for editing.", style="bold red")
raise typer.Exit(code=1)
keys = key.split(".")
if len(keys) != 2:
console.print("❌ Invalid key format. Use 'section.name' format.", style="bold red")
raise typer.Exit(code=1)
section, name = keys
section_dict = getattr(config, section, None)
if section_dict and hasattr(section_dict, name):
setattr(section_dict, name, val)
config.save_to_file()
console.print("✅ Configuration updated successfully.", style="bold green")
else:
console.print(
f"❌ Invalid configuration name: {name} in section: {section}",
style="bold red",
)
raise typer.Exit(code=1)
else:
console.print(f"❌ Invalid action: {action}", style="bold red")
raise typer.Exit(code=1)
@cli.command(help="Run tool calling evaluations", rich_help_panel="Tool Development") @cli.command(help="Run tool calling evaluations", rich_help_panel="Tool Development")
def evals( def evals(
directory: str = typer.Argument(".", help="Directory containing evaluation files"), directory: str = typer.Argument(".", help="Directory containing evaluation files"),
@ -360,7 +321,7 @@ def evals(
help="The models to use for evaluation (default: gpt-4o)", help="The models to use for evaluation (default: gpt-4o)",
), ),
host: str = typer.Option( host: str = typer.Option(
None, DEFAULT_ENGINE_HOST,
"-h", "-h",
"--host", "--host",
help="The Arcade Engine address to send chat requests to.", help="The Arcade Engine address to send chat requests to.",
@ -386,7 +347,8 @@ def evals(
Find all files starting with 'eval_' in the given directory, Find all files starting with 'eval_' in the given directory,
execute any functions decorated with @tool_eval, and display the results. execute any functions decorated with @tool_eval, and display the results.
""" """
config = get_config_with_overrides(force_tls, force_no_tls, host, port) config = validate_and_get_config()
base_url = compute_base_url(force_tls, force_no_tls, host, port)
models_list = models.split(",") # Use 'models_list' to avoid shadowing models_list = models.split(",") # Use 'models_list' to avoid shadowing
@ -398,12 +360,12 @@ def evals(
console.print( console.print(
Text.assemble( Text.assemble(
("\nRunning evaluations against Arcade Engine at ", "bold"), ("\nRunning evaluations against Arcade Engine at ", "bold"),
(config.engine_url, "bold blue"), (base_url, "bold blue"),
) )
) )
# Try to hit /health endpoint on engine and warn if it is down # Try to hit /health endpoint on engine and warn if it is down
with Arcade(api_key=config.api.key, base_url=config.engine_url) as client: with Arcade(api_key=config.api.key, base_url=base_url) as client:
log_engine_health(client) log_engine_health(client)
# Use the new function to load eval suites # Use the new function to load eval suites
@ -432,7 +394,12 @@ def evals(
) )
for model in models_list: for model in models_list:
task = asyncio.create_task( task = asyncio.create_task(
suite_func(config=config, model=model, max_concurrency=max_concurrent) suite_func(
config=config,
base_url=base_url,
model=model,
max_concurrency=max_concurrent,
)
) )
tasks.append(task) tasks.append(task)

View file

@ -1,9 +1,13 @@
import importlib.util import importlib.util
import ipaddress
import os
import webbrowser import webbrowser
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Union, cast from typing import Any, Callable, Union, cast
from urllib.parse import urlparse
import idna
import typer import typer
from arcadepy import NOT_GIVEN, APIConnectionError, APIStatusError, APITimeoutError, Arcade from arcadepy import NOT_GIVEN, APIConnectionError, APIStatusError, APITimeoutError, Arcade
from arcadepy.types import AuthorizationResponse from arcadepy.types import AuthorizationResponse
@ -66,25 +70,85 @@ def create_cli_catalog(
return catalog return catalog
def get_config_with_overrides( def compute_base_url(
force_tls: bool, force_tls: bool,
force_no_tls: bool, force_no_tls: bool,
host_input: str | None = None, host: str,
port_input: int | None = None, port: int | None,
) -> Config: ) -> str:
""" """
Get the config with CLI-specific optional overrides applied. Compute the base URL for the Arcade Engine from the provided overrides.
"""
config = validate_and_get_config()
if not force_tls and not force_no_tls: force_no_tls takes precedence over force_tls. For example, if both are set to True,
tls_input = None the resulting URL will use http.
elif force_no_tls:
tls_input = False 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 api_version to specify the API version.
Returns:
str: The fully constructed URL for the Arcade Engine.
"""
# Determine TLS setting based on input flags
if force_no_tls:
is_tls = False
elif force_tls:
is_tls = True
else: else:
tls_input = True is_tls = host != "localhost"
apply_config_overrides(config, host_input, port_input, tls_input)
return config # "localhost" defaults to dev port if not specified
if host == "localhost" and port is None:
port = 9099
protocol = "https" if is_tls else "http"
# Handle potential IDNs
try:
encoded_host = idna.encode(host).decode("ascii")
except idna.IDNAError:
encoded_host = 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}"
if is_fqdn and port is None:
return f"{protocol}://{encoded_host}"
elif port is not None:
return f"{protocol}://{encoded_host}:{port}"
else:
return f"{protocol}://{encoded_host}"
def get_tools_from_engine( def get_tools_from_engine(
@ -94,8 +158,9 @@ def get_tools_from_engine(
force_no_tls: bool = False, force_no_tls: bool = False,
toolkit: str | None = None, toolkit: str | None = None,
) -> list[ToolDefinition]: ) -> list[ToolDefinition]:
config = get_config_with_overrides(force_tls, force_no_tls, host, port) config = validate_and_get_config()
client = Arcade(api_key=config.api.key, base_url=config.engine_url) base_url = compute_base_url(force_tls, force_no_tls, host, port)
client = Arcade(api_key=config.api.key, base_url=base_url)
tools = [] tools = []
page_iterator = client.tools.list(toolkit=toolkit or NOT_GIVEN) page_iterator = client.tools.list(toolkit=toolkit or NOT_GIVEN)
@ -177,7 +242,6 @@ def markdownify_urls(message: str) -> str:
def validate_and_get_config( def validate_and_get_config(
validate_engine: bool = True,
validate_api: bool = True, validate_api: bool = True,
validate_user: bool = True, validate_user: bool = True,
) -> Config: ) -> Config:
@ -186,10 +250,6 @@ def validate_and_get_config(
""" """
from arcade.core.config import config 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): if validate_api and (not config.api or not config.api.key):
console.print( console.print(
"❌ API configuration not found or key is missing. Please run `arcade login`.", "❌ API configuration not found or key is missing. Please run `arcade login`.",
@ -206,35 +266,6 @@ def validate_and_get_config(
return config return config
def apply_config_overrides(
config: Config, host_input: str | None, port_input: int | None, tls_input: bool | None
) -> None:
"""
Apply optional config overrides (passed by the user) to the config object.
"""
if not config.engine:
# Should not happen, validate_and_get_config ensures that `engine` is set
raise ValueError("Engine configuration not found in config.")
# Special case for "localhost" and nothing else specified:
# default to dev port and no TLS for convenience
if host_input == "localhost":
if port_input is None:
port_input = 9099
if tls_input is None:
tls_input = False
if host_input:
config.engine.host = host_input
if port_input is not None:
config.engine.port = port_input
if tls_input is not None:
config.engine.tls = tls_input
def log_engine_health(client: Arcade) -> None: def log_engine_health(client: Arcade) -> None:
try: try:
result = client.health.check(timeout=2) result = client.health.check(timeout=2)
@ -488,3 +519,48 @@ def load_eval_suites(eval_files: list[Path]) -> list[Callable]:
eval_suites.extend(eval_suite_funcs) eval_suites.extend(eval_suite_funcs)
return eval_suites return eval_suites
def create_new_env_file() -> None:
"""
Create a new env file if one doesn't already exist.
"""
env_file = os.path.expanduser("~/.arcade/arcade.env")
if not os.path.exists(env_file):
template_path = os.path.join(
os.path.dirname(__file__), "..", "templates", "arcade.template.env"
)
os.makedirs(os.path.dirname(env_file), exist_ok=True)
with open(template_path) as template_file, open(env_file, "w") as new_env_file:
template_contents = template_file.read()
new_env_file.write(template_contents)
console.print(f"Created new environment file at {env_file}", style="bold green")
def is_config_file_deprecated() -> bool:
"""
Check if the user is using the deprecated config file.
Returns:
bool: True if the user is using the deprecated config file, False otherwise.
"""
deprecated_config_file_path = os.path.expanduser("~/.arcade/arcade.toml")
if os.path.exists(deprecated_config_file_path):
console.print(
f"Deprecation Notice: You are using a deprecated config file at {deprecated_config_file_path}. Please migrate to the new format by running,\n\n\t$ arcade logout && arcade login\n",
style="bold yellow",
)
return True
return False
def delete_deprecated_config_file() -> None:
"""
Delete the deprecated config file if it exists.
"""
deprecated_config_file_path = os.path.expanduser("~/.arcade/arcade.toml")
if os.path.exists(deprecated_config_file_path):
os.remove(deprecated_config_file_path)

View file

@ -1,11 +1,9 @@
import ipaddress
import os import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urlparse
import idna
import toml import toml
import yaml
from pydantic import BaseModel, ConfigDict, ValidationError from pydantic import BaseModel, ConfigDict, ValidationError
@ -39,25 +37,6 @@ class UserConfig(BaseConfig):
""" """
class EngineConfig(BaseConfig):
"""
Arcade Engine configuration.
"""
host: str = "api.arcade-ai.com"
"""
Arcade Engine host.
"""
port: int | None = None
"""
Arcade Engine port.
"""
tls: bool = True
"""
Whether to use TLS for the connection to Arcade Engine.
"""
class Config(BaseConfig): class Config(BaseConfig):
""" """
Configuration for Arcade. Configuration for Arcade.
@ -71,15 +50,9 @@ class Config(BaseConfig):
""" """
Arcade user configuration. Arcade user configuration.
""" """
engine: EngineConfig | None = EngineConfig()
"""
Arcade Engine configuration.
"""
def __init__(self, **data: Any): def __init__(self, **data: Any):
super().__init__(**data) super().__init__(**data)
self._engine_url_cache: str | None = None
self._engine_url_cache_key: str | None = None
@classmethod @classmethod
def get_config_dir_path(cls) -> Path: def get_config_dir_path(cls) -> Path:
@ -89,98 +62,19 @@ class Config(BaseConfig):
config_path = os.getenv("ARCADE_WORK_DIR") or Path.home() / ".arcade" config_path = os.getenv("ARCADE_WORK_DIR") or Path.home() / ".arcade"
return Path(config_path).resolve() return Path(config_path).resolve()
@classmethod
def get_deprecated_config_file_path(cls) -> Path:
"""
Get the path to the deprecated Arcade configuration file.
"""
return cls.get_config_dir_path() / "arcade.toml"
@classmethod @classmethod
def get_config_file_path(cls) -> Path: def get_config_file_path(cls) -> Path:
""" """
Get the path to the Arcade configuration file. Get the path to the Arcade configuration file.
""" """
return cls.get_config_dir_path() / "arcade.toml" return cls.get_config_dir_path() / "credentials.yaml"
def _generate_engine_url_cache_key(self) -> str:
"""
Generate a cache key for the engine_url property, based on its underlying data.
"""
if self.engine is None:
return ""
return f"{self.engine.host}:{self.engine.port}:{self.engine.tls}"
@property
def engine_url(self) -> str:
"""
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.
Returns:
str: The fully constructed URL for the Arcade Engine.
Raises:
ValueError: If the engine configuration is missing or incomplete.
"""
current_cache_key = self._generate_engine_url_cache_key()
if self._engine_url_cache is None or self._engine_url_cache_key != current_cache_key:
self._engine_url_cache = self._compute_engine_url()
self._engine_url_cache_key = current_cache_key
return self._engine_url_cache
def _compute_engine_url(self) -> str:
if self.engine is None:
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"
# 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}"
if is_fqdn and self.engine.port is None:
return f"{protocol}://{encoded_host}"
elif self.engine.port is not None:
return f"{protocol}://{encoded_host}:{self.engine.port}"
else:
return f"{protocol}://{encoded_host}"
@classmethod @classmethod
def ensure_config_dir_exists(cls) -> None: def ensure_config_dir_exists(cls) -> None:
@ -194,7 +88,7 @@ class Config(BaseConfig):
@classmethod @classmethod
def load_from_file(cls) -> "Config": def load_from_file(cls) -> "Config":
""" """
Load the configuration from the TOML file in the configuration directory. Load the configuration from the YAML file in the configuration directory.
If no configuration file exists, this method will 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: The default configuration includes:
@ -202,30 +96,30 @@ class Config(BaseConfig):
- A default Engine configuration (host: "api.arcade-ai.com", port: None, tls: True) - A default Engine configuration (host: "api.arcade-ai.com", port: None, tls: True)
- No user configuration - No user configuration
This behavior ensures that the application always has a valid configuration to work with, If a deprecated TOML configuration file is found, it will be automatically converted
but it may not be suitable for all use cases. If a specific configuration is required, to the new YAML format. This ensures that the application always has a valid configuration
ensure that the configuration file exists before calling this method. 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: Returns:
Config: The loaded or newly created configuration. Config: The loaded or newly created configuration.
Raises: Raises:
ValueError: If the existing configuration file is invalid. ValueError: If the existing configuration file is invalid or cannot be converted.
""" """
cls.ensure_config_dir_exists() cls.ensure_config_dir_exists()
config_file_path = cls.get_config_file_path() config_file_path = cls.get_config_file_path()
if not config_file_path.exists():
if not config_file_path.exists() and not cls._migrate_deprecated_config_file():
# Create a file using the default configuration # Create a file using the default configuration
default_config = cls.model_construct( default_config = cls.model_construct(api=ApiConfig.model_construct())
api=ApiConfig.model_construct(), engine=EngineConfig()
)
default_config.save_to_file() default_config.save_to_file()
config_data = toml.loads(config_file_path.read_text()) config_data = yaml.safe_load(config_file_path.read_text())
try: try:
return cls(**config_data) return cls(**config_data["cloud"])
except ValidationError as e: except ValidationError as e:
# Get only the errors with {type:missing} and combine them # Get only the errors with {type:missing} and combine them
# into a nicely-formatted string message. # into a nicely-formatted string message.
@ -250,8 +144,36 @@ class Config(BaseConfig):
def save_to_file(self) -> None: def save_to_file(self) -> None:
""" """
Save the configuration to the TOML file in the configuration directory. Save the configuration to the YAML file in the configuration directory.
""" """
Config.ensure_config_dir_exists() Config.ensure_config_dir_exists()
config_file_path = Config.get_config_file_path() config_file_path = Config.get_config_file_path()
config_file_path.write_text(toml.dumps(self.model_dump())) config_file_path.write_text(yaml.dump(self.model_dump()))
@classmethod
def _migrate_deprecated_config_file(cls) -> bool:
"""
Migrate the deprecated config file to the new format if the deprecated config file exists.
Returns:
bool: True if the migration occurred, False otherwise.
"""
deprecated_config_file_path = Config.get_deprecated_config_file_path()
if deprecated_config_file_path.exists():
# If the user is using the deprecated config file, then convert it to the new yaml format
try:
old_config: dict[str, Any] = toml.load(deprecated_config_file_path)
old_config = {"cloud": old_config}
with open(cls.get_config_file_path(), "w") as f:
yaml.dump(old_config, f)
os.remove(deprecated_config_file_path)
print(
f"\033[1;33mAutomatically migrated the deprecated config file {deprecated_config_file_path} to {cls.get_config_file_path()}\033[0m"
)
except Exception as e:
raise OSError(
f"Invalid configuration file at {deprecated_config_file_path} could not be automatically converted to the new format. Please manually migrate to {cls.get_config_file_path()} by running `arcade logout && arcade login`."
) from e
return True
return False

View file

@ -644,6 +644,7 @@ def tool_eval() -> Callable[[Callable], Callable]:
@functools.wraps(func) @functools.wraps(func)
async def wrapper( async def wrapper(
config: Config, config: Config,
base_url: str,
model: str, model: str,
max_concurrency: int = 1, max_concurrency: int = 1,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@ -654,7 +655,7 @@ def tool_eval() -> Callable[[Callable], Callable]:
results = [] results = []
async with AsyncOpenAI( async with AsyncOpenAI(
api_key=config.api.key, api_key=config.api.key,
base_url=config.engine_url + "/v1", # TODO remove base_url=base_url + "/v1",
) as client: ) as client:
result = await suite.run(client, model) result = await suite.run(client, model)
results.append(result) results.append(result)

View file

@ -0,0 +1,34 @@
### LLM API KEY ###
# ANTHROPIC_API_KEY=...
# OPENAI_API_KEY=...
# ...
### Integrations ###
# GITHUB_CLIENT_ID=...
# GITHUB_CLIENT_SECRET=...
# GOOGLE_CLIENT_ID=...
# GOOGLE_CLIENT_SECRET=...
# LINKEDIN_CLIENT_ID=...
# LINKEDIN_CLIENT_SECRET=...
# MICROSOFT_CLIENT_ID=...
# MICROSOFT_CLIENT_SECRET=...
# SLACK_CLIENT_ID=...
# SLACK_CLIENT_SECRET=...
# SPOTIFY_CLIENT_ID=...
# SPOTIFY_CLIENT_SECRET=...
# X_CLIENT_ID=...
# X_CLIENT_SECRET=...
# ZOOM_CLIENT_ID=...
# ZOOM_CLIENT_SECRET=...
#...
# ...

View file

@ -50,6 +50,7 @@ pytest-asyncio = "^0.23.7"
types-toml = "^0.10.8" types-toml = "^0.10.8"
types-pytz = "^2024.1" types-pytz = "^2024.1"
types-python-dateutil = "^2.8.2" types-python-dateutil = "^2.8.2"
types-PyYAML = "^6.0.0"
poetry-plugin-export = "^1.7.0" poetry-plugin-export = "^1.7.0"
[tool.poetry.scripts] [tool.poetry.scripts]

View file

@ -1,135 +1,195 @@
import pytest import pytest
from arcade.cli.utils import apply_config_overrides from arcade.cli.utils import compute_base_url
from arcade.core.config_model import ApiConfig, Config, EngineConfig
DEFAULT_HOST = "api.arcade-ai.com" DEFAULT_HOST = "api.arcade-ai.com"
LOCALHOST = "localhost"
DEFAULT_PORT = None DEFAULT_PORT = None
DEFAULT_TLS = True DEFAULT_FORCE_TLS = False
DEFAULT_FORCE_NO_TLS = False
@pytest.mark.parametrize( @pytest.mark.parametrize(
"inputs, expected_outputs", "inputs, expected_output",
[ [
pytest.param( pytest.param(
{ {
"host_input": None, "host_input": DEFAULT_HOST,
"port_input": None, "port_input": DEFAULT_PORT,
"tls_input": None, "force_tls": DEFAULT_FORCE_TLS,
"force_no_tls": DEFAULT_FORCE_NO_TLS,
}, },
{ "https://api.arcade-ai.com",
"host": DEFAULT_HOST, id="default",
"port": DEFAULT_PORT,
"tls": DEFAULT_TLS,
},
id="noop",
), ),
pytest.param( pytest.param(
{ {
"host_input": "api2.arcade-ai.com", "host_input": LOCALHOST,
"port_input": None, "port_input": DEFAULT_PORT,
"tls_input": None, "force_tls": DEFAULT_FORCE_TLS,
"force_no_tls": DEFAULT_FORCE_NO_TLS,
}, },
{ "http://localhost:9099",
"host": "api2.arcade-ai.com", id="localhost",
"port": DEFAULT_PORT,
"tls": DEFAULT_TLS,
},
id="set host",
), ),
pytest.param( pytest.param(
{ {
"host_input": None, "host_input": DEFAULT_HOST,
"port_input": 6789, "port_input": 9099,
"tls_input": None, "force_tls": DEFAULT_FORCE_TLS,
"force_no_tls": DEFAULT_FORCE_NO_TLS,
}, },
{ "https://api.arcade-ai.com:9099",
"host": DEFAULT_HOST, id="custom port",
"port": 6789,
"tls": DEFAULT_TLS,
},
id="set port",
), ),
pytest.param( pytest.param(
{ {
"host_input": None, "host_input": LOCALHOST,
"port_input": None, "port_input": 9099,
"tls_input": False, "force_tls": DEFAULT_FORCE_TLS,
"force_no_tls": DEFAULT_FORCE_NO_TLS,
}, },
{ "http://localhost:9099",
"host": DEFAULT_HOST, id="localhost with custom port",
"port": DEFAULT_PORT,
"tls": False,
},
id="set TLS to False",
), ),
pytest.param( pytest.param(
{ {
"host_input": None, "host_input": DEFAULT_HOST,
"port_input": None, "port_input": DEFAULT_PORT,
"tls_input": True, "force_tls": True,
"force_no_tls": DEFAULT_FORCE_NO_TLS,
}, },
{ "https://api.arcade-ai.com",
"host": DEFAULT_HOST, id="force TLS",
"port": DEFAULT_PORT,
"tls": True,
},
id="set TLS to True",
), ),
pytest.param( pytest.param(
{ {
"host_input": "localhost", "host_input": LOCALHOST,
"port_input": None, "port_input": DEFAULT_PORT,
"tls_input": None, "force_tls": True,
"force_no_tls": DEFAULT_FORCE_NO_TLS,
}, },
{ "https://localhost:9099",
"host": "localhost", id="localhost with force TLS",
"port": 9099,
"tls": False,
},
id="localhost and no port or TLS specified",
), ),
pytest.param( pytest.param(
{ {
"host_input": "localhost", "host_input": DEFAULT_HOST,
"port_input": 1234, "port_input": 9099,
"tls_input": None, "force_tls": True,
"force_no_tls": DEFAULT_FORCE_NO_TLS,
}, },
{ "https://api.arcade-ai.com:9099",
"host": "localhost", id="custom port with force TLS",
"port": 1234,
"tls": False,
},
id="localhost and port specified",
), ),
pytest.param( pytest.param(
{ {
"host_input": "localhost", "host_input": LOCALHOST,
"port_input": None, "port_input": 9099,
"tls_input": True, "force_tls": True,
"force_no_tls": DEFAULT_FORCE_NO_TLS,
}, },
"https://localhost:9099",
id="localhost with custom port and force TLS",
),
pytest.param(
{ {
"host": "localhost", "host_input": DEFAULT_HOST,
"port": 9099, "port_input": DEFAULT_PORT,
"tls": True, "force_tls": DEFAULT_FORCE_TLS,
"force_no_tls": True,
}, },
id="localhost and TLS specified", "http://api.arcade-ai.com",
id="force no TLS",
),
pytest.param(
{
"host_input": LOCALHOST,
"port_input": DEFAULT_PORT,
"force_tls": DEFAULT_FORCE_TLS,
"force_no_tls": True,
},
"http://localhost:9099",
id="localhost with force no TLS",
),
pytest.param(
{
"host_input": DEFAULT_HOST,
"port_input": 9099,
"force_tls": DEFAULT_FORCE_TLS,
"force_no_tls": True,
},
"http://api.arcade-ai.com:9099",
id="custom port with force no TLS",
),
pytest.param(
{
"host_input": LOCALHOST,
"port_input": 9099,
"force_tls": DEFAULT_FORCE_TLS,
"force_no_tls": True,
},
"http://localhost:9099",
id="localhost with custom port and force no TLS",
),
pytest.param(
{
"host_input": DEFAULT_HOST,
"port_input": DEFAULT_PORT,
"force_tls": True,
"force_no_tls": True,
},
"http://api.arcade-ai.com",
id="force TLS and no TLS",
),
pytest.param(
{
"host_input": LOCALHOST,
"port_input": DEFAULT_PORT,
"force_tls": True,
"force_no_tls": True,
},
"http://localhost:9099",
id="localhost with force TLS and no TLS",
),
pytest.param(
{
"host_input": DEFAULT_HOST,
"port_input": 9099,
"force_tls": True,
"force_no_tls": True,
},
"http://api.arcade-ai.com:9099",
id="custom port with force TLS and no TLS",
),
pytest.param(
{
"host_input": LOCALHOST,
"port_input": 9099,
"force_tls": True,
"force_no_tls": True,
},
"http://localhost:9099",
id="localhost with custom port, force TLS and no TLS",
),
pytest.param(
{
"host_input": "arandomhost.com",
"port_input": DEFAULT_PORT,
"force_tls": DEFAULT_FORCE_TLS,
"force_no_tls": DEFAULT_FORCE_NO_TLS,
},
"https://arandomhost.com",
id="random host",
), ),
], ],
) )
def test_apply_config_overrides(inputs: dict, expected_outputs: dict): def test_compute_base_url(inputs: dict, expected_output: str):
# Set fake default values for testing base_url = compute_base_url(
config = Config( inputs["force_tls"],
api=ApiConfig(key="fake_api_key"), inputs["force_no_tls"],
engine=EngineConfig( inputs["host_input"],
host=DEFAULT_HOST, inputs["port_input"],
port=DEFAULT_PORT,
tls=DEFAULT_TLS,
),
) )
apply_config_overrides(config, inputs["host_input"], inputs["port_input"], inputs["tls_input"]) assert base_url == expected_output
assert config.engine.host == expected_outputs["host"]
assert config.engine.port == expected_outputs["port"]
assert config.engine.tls == expected_outputs["tls"]