logic-engine / ace /cli /setup.py
ghostdrive1's picture
Upload folder using huggingface_hub
116524e verified
Raw
History Blame Contribute Delete
17 kB
"""``ace setup`` — interactive configuration wizard.
Guides the user through:
1. Enter a model name (any LiteLLM model string)
2. Validate the connection — if it fails, prompt for keys
3. Optionally assign different models per ACE role
4. Save .env (secrets) and ace.toml (model config)
"""
from __future__ import annotations
import getpass
import os
import sys
from pathlib import Path
from ..providers.config import (
ACEModelConfig,
ModelConfig,
find_config,
load_config,
load_dotenv,
save_config,
save_env_var,
)
from ..providers.registry import (
PROVIDER_KEY_ENV,
_PROVIDER_ALT_KEYS,
get_missing_keys,
get_provider,
search_models,
suggest_models,
validate_connection,
)
# ---------------------------------------------------------------------------
# Terminal helpers
# ---------------------------------------------------------------------------
_IS_TTY = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
BOLD = "\033[1m" if _IS_TTY else ""
DIM = "\033[2m" if _IS_TTY else ""
GREEN = "\033[32m" if _IS_TTY else ""
RED = "\033[31m" if _IS_TTY else ""
YELLOW = "\033[33m" if _IS_TTY else ""
CYAN = "\033[36m" if _IS_TTY else ""
RESET = "\033[0m" if _IS_TTY else ""
def _ok(msg: str) -> None:
print(f" {GREEN}\u2713{RESET} {msg}")
def _warn(msg: str) -> None:
print(f" {YELLOW}!{RESET} {msg}")
def _fail(msg: str) -> None:
print(f" {RED}\u2717{RESET} {msg}")
def _info(msg: str) -> None:
print(f" {DIM}{msg}{RESET}")
def _prompt(label: str, default: str = "") -> str:
suffix = f" [{default}]" if default else ""
try:
value = input(f" {label}{suffix}: ").strip()
except (EOFError, KeyboardInterrupt):
print()
sys.exit(1)
return value or default
def _prompt_secret(label: str) -> str:
try:
value = getpass.getpass(f" {label}: ").strip()
except (EOFError, KeyboardInterrupt):
print()
sys.exit(1)
return value
def _confirm(label: str, default: bool = True) -> bool:
suffix = "[Y/n]" if default else "[y/N]"
try:
value = input(f" {label} {suffix}: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print()
sys.exit(1)
if not value:
return default
return value in ("y", "yes")
def _load_project_dotenv() -> None:
"""Load .env from the project root (where ace.toml lives), not just CWD."""
config_path = find_config()
if config_path is not None:
env_path = config_path.parent / ".env"
if env_path.exists():
try:
from dotenv import load_dotenv as _load
_load(env_path)
return
except ImportError:
pass
# Fallback: try CWD
load_dotenv()
# ---------------------------------------------------------------------------
# Model + key flow
# ---------------------------------------------------------------------------
def _detect_credential_source(provider: str) -> str | None:
"""Return which credential env var is set for *provider*."""
env_vars = PROVIDER_KEY_ENV.get(provider)
if env_vars is None:
candidates: list[str] = []
elif isinstance(env_vars, str):
candidates = [env_vars]
else:
candidates = list(env_vars)
# Include alternative auth (e.g. AWS_BEARER_TOKEN_BEDROCK)
alt = _PROVIDER_ALT_KEYS.get(provider)
if alt:
candidates.extend(alt)
found = [v for v in candidates if os.environ.get(v)]
if not found:
return None
return ", ".join(found)
def _validate_and_prompt_keys(
model: str,
provider: str,
directory: Path,
) -> bool:
"""Try to validate *model*. If auth fails, prompt for missing keys and retry.
Returns True on success. On non-auth failures (model not found, etc.)
prints the error and returns False so the caller can re-prompt.
"""
# First: just try it — handles bearer tokens, ~/.aws/credentials, etc.
print(f" Validating...", end="", flush=True)
result = validate_connection(model)
if result.success:
print(
f"\r {GREEN}\u2713{RESET} Connected! "
f"({model} via {result.provider}, {result.latency_ms}ms)"
)
# Show which credential was used
cred_source = _detect_credential_source(result.provider or provider)
if cred_source:
_info(f"Using {cred_source}")
return True
# Model not found — not recoverable by adding keys
if "not found" in result.error.lower():
print(f"\r {RED}\u2717{RESET} {result.error} ")
suggestions = suggest_models(model)
if suggestions:
_info("Did you mean one of these?")
for s in suggestions:
_info(f" - {s}")
return False
# Everything else (auth, connection, bad request, etc.) — offer to
# prompt for credentials since missing/wrong keys are the most common cause.
print(f"\r {YELLOW}!{RESET} {result.error} ")
_info(f"This may be a credentials issue for {provider}.")
# Prefer our own mapping over LiteLLM's generic response, since
# LiteLLM often returns wrong keys (e.g. bedrock_converse gets
# generic bedrock keys instead of the bearer token alternative).
our_keys = PROVIDER_KEY_ENV.get(provider)
if our_keys is not None:
missing = [our_keys] if isinstance(our_keys, str) else list(our_keys)
else:
missing = get_missing_keys(model)
if not missing:
missing = [f"{provider.upper()}_API_KEY"]
# Env vars that are not secrets — prompt with visible input
_NON_SECRET_VARS = {"AWS_REGION_NAME", "GOOGLE_APPLICATION_CREDENTIALS"}
provided_keys: dict[str, str] = {}
for env_var in missing:
if env_var in _NON_SECRET_VARS:
value = _prompt(env_var)
else:
value = _prompt_secret(f"{env_var}")
if value:
provided_keys[env_var] = value
os.environ[env_var] = value
if not provided_keys:
_fail("No credentials provided.")
return False
# Retry validation
print(f" Validating...", end="", flush=True)
result = validate_connection(model)
if result.success:
print(
f"\r {GREEN}\u2713{RESET} Connected! "
f"({model} via {result.provider}, {result.latency_ms}ms)"
)
# Persist keys to .env
for env_var, value in provided_keys.items():
save_env_var(env_var, value, directory)
_ok(f"Saved credentials to .env")
return True
else:
print(f"\r {RED}\u2717{RESET} {result.error} ")
# Roll back
for env_var in provided_keys:
os.environ.pop(env_var, None)
return False
def _setup_model(
role_label: str,
directory: Path,
*,
default_model: str = "",
) -> str:
"""Prompt for model, validate connection. Return the validated model string.
Loops until validation succeeds or the user quits (Ctrl-C).
"""
while True:
model = _prompt(f"{role_label} model", default=default_model)
if not model:
continue
provider = get_provider(model)
if provider == "unknown":
_fail(f"Could not detect a provider for '{model}'.")
_info("Use the format: provider/model-name (e.g. groq/llama-3.1-70b)")
suggestions = suggest_models(model)
if suggestions:
_info("Did you mean one of these?")
for s in suggestions[:5]:
_info(f" - {s}")
print()
continue
if _validate_and_prompt_keys(model, provider, directory):
return model
print() # blank line before retry
# ---------------------------------------------------------------------------
# Main setup flow
# ---------------------------------------------------------------------------
def run_setup(directory: str | Path = ".") -> ACEModelConfig:
"""Run the interactive setup wizard. Returns the saved config."""
directory = Path(directory).resolve()
print()
print(f"{BOLD}ACE Setup{RESET}")
print()
# Load existing .env if present
load_dotenv()
# Check for existing config
existing = find_config(directory)
if existing:
try:
old = load_config(existing.parent)
_info(f"Found existing config: {existing}")
_info(f" Default model: {old.default.model}")
for role in ("agent", "reflector", "skill_manager"):
cfg = getattr(old, role)
if cfg:
_info(f" {role}: {cfg.model}")
print()
if not _confirm("Reconfigure?"):
print()
_ok("Keeping existing config.")
return old
print()
except Exception:
pass # corrupted config — just reconfigure
# Step 1: Default model
print(f"{BOLD}Step 1: Choose your model{RESET}")
print()
_info("Examples: gpt-4o-mini, claude-sonnet-4-20250514, ollama/llama2")
_info(f"Search models: {CYAN}ace models <query>{RESET}")
print()
default_model = _setup_model("Default", directory)
print()
# Step 2: Per-role assignment
print(f"{BOLD}Step 2: Role assignment{RESET}")
print()
_info("ACE uses three roles. You can assign a different model to each,")
_info("or use the same model for all (recommended to start).")
print()
use_same = _confirm("Use this model for all roles?")
agent_cfg: ModelConfig | None = None
reflector_cfg: ModelConfig | None = None
skill_manager_cfg: ModelConfig | None = None
if not use_same:
print()
_info("Press Enter to keep the default for any role.")
print()
for role_name, label in [
("agent", "Agent (executes tasks)"),
("reflector", "Reflector (analyses results)"),
("skill_manager", "Skill Manager (updates skillbook)"),
]:
model = _prompt(label, default=default_model)
if model != default_model:
model = _setup_model(label, directory, default_model=model)
if role_name == "agent":
agent_cfg = ModelConfig(model=model)
elif role_name == "reflector":
reflector_cfg = ModelConfig(model=model)
else:
skill_manager_cfg = ModelConfig(model=model)
# Build and save config
config = ACEModelConfig(
default=ModelConfig(model=default_model),
agent=agent_cfg,
reflector=reflector_cfg,
skill_manager=skill_manager_cfg,
)
config_path = save_config(config, directory)
print()
_ok(f"Saved model config to {config_path.name}")
# Summary
print()
print(f" {BOLD}Configuration summary:{RESET}")
_info(f" default: {default_model}")
for role in ("agent", "reflector", "skill_manager"):
cfg = getattr(config, role)
if cfg:
_info(f" {role + ':':<16}{cfg.model}")
print()
print(f" {BOLD}Ready!{RESET} Use in code:")
print()
print(f" {CYAN}from ace import ACELiteLLM{RESET}")
print(f" {CYAN}ace = ACELiteLLM.from_setup(){RESET}")
print()
return config
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point for ``ace`` CLI."""
import argparse
parser = argparse.ArgumentParser(
prog="ace",
description="ACE Framework CLI",
)
subparsers = parser.add_subparsers(dest="command")
# ace setup
setup_parser = subparsers.add_parser("setup", help="Configure models and API keys")
setup_parser.add_argument(
"--dir",
default=".",
help="Directory to save config files (default: current directory)",
)
# ace models
models_parser = subparsers.add_parser("models", help="Search available models")
models_parser.add_argument(
"query", nargs="*", default=[], help="Search query (multiple terms = match all)"
)
models_parser.add_argument("--provider", default=None, help="Filter by provider")
models_parser.add_argument(
"--limit", type=int, default=20, help="Max results (default: 20)"
)
# ace validate
validate_parser = subparsers.add_parser(
"validate", help="Validate a model connection"
)
validate_parser.add_argument("model", help="Model name to validate")
# ace config
subparsers.add_parser("config", help="Show current configuration")
args = parser.parse_args()
if args.command == "setup":
run_setup(args.dir)
elif args.command == "models":
_cmd_models(" ".join(args.query), args.provider, args.limit)
elif args.command == "validate":
_cmd_validate(args.model)
elif args.command == "config":
_cmd_config()
else:
parser.print_help()
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def _cmd_models(query: str, provider: str | None, limit: int) -> None:
"""``ace models [query]`` — search available models."""
if not query and not provider:
print(f"Usage: {CYAN}ace models <query>{RESET}")
print()
print("Examples:")
print(f" {CYAN}ace models claude{RESET} All Claude models")
print(f" {CYAN}ace models gpt 4o{RESET} GPT-4o variants")
print(f" {CYAN}ace models haiku us{RESET} US-region Haiku models")
print(f" {CYAN}ace models --provider openai{RESET} All OpenAI models")
return
_load_project_dotenv()
results, total = search_models(query=query, provider=provider, limit=limit)
if not results:
print(f"No models matching '{query}'.")
print(f"Try: {CYAN}ace models gpt-4o{RESET} or {CYAN}ace models claude{RESET}")
return
print(
f"{'Model':<45} {'Provider':<15} {'Input $/M':<10} {'Output $/M':<11} {'Key'}"
)
print("-" * 90)
for m in results:
in_cost = f"${m.input_cost_per_m:.2f}" if m.input_cost_per_m else "-"
out_cost = f"${m.output_cost_per_m:.2f}" if m.output_cost_per_m else "-"
key_status = f"{GREEN}\u2713{RESET}" if m.key_found else f"{RED}\u2717{RESET}"
print(
f"{m.model:<45} {m.provider:<15} {in_cost:<10} {out_cost:<11} {key_status}"
)
if total > limit:
print()
print(
f"{DIM}Showing {limit} of {total} models. "
f"Narrow your search: {CYAN}ace models <query>{RESET}"
f"{DIM} or use {CYAN}--limit {total}{RESET}"
)
def _cmd_validate(model: str) -> None:
"""``ace validate <model>`` — test a model connection."""
_load_project_dotenv()
print(f"Validating {model}...", end="", flush=True)
result = validate_connection(model)
if result.success:
print(
f"\r{GREEN}\u2713{RESET} Connected! "
f"({model} via {result.provider}, {result.latency_ms}ms)"
)
else:
print(f"\r{RED}\u2717{RESET} {result.error}")
suggestions = suggest_models(model)
if suggestions:
print("Did you mean:")
for s in suggestions:
print(f" - {s}")
sys.exit(1)
def _cmd_config() -> None:
"""``ace config`` — show current configuration."""
_load_project_dotenv()
config_path = find_config()
if config_path is None:
print(f"No ace.toml found. Run {CYAN}ace setup{RESET} to create one.")
sys.exit(1)
try:
config = load_config(config_path.parent)
except Exception as e:
_fail(f"Error reading {config_path}: {e}")
sys.exit(1)
print(f"{BOLD}Configuration{RESET} ({config_path})")
print()
print(f" {'Role':<16} {'Model':<45}")
print(f" {'-' * 16} {'-' * 45}")
print(f" {'default':<16} {config.default.model}")
for role in ("agent", "reflector", "skill_manager"):
cfg = getattr(config, role)
model = cfg.model if cfg else f"{DIM}(default){RESET}"
print(f" {role:<16} {model}")