Spaces:
Sleeping
Sleeping
| """``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}") | |