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:
parent
10030c6a12
commit
8508a28f54
12 changed files with 451 additions and 380 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
34
arcade/arcade/templates/arcade.template.env
Normal file
34
arcade/arcade/templates/arcade.template.env
Normal 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=...
|
||||||
|
#...
|
||||||
|
# ...
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue