logic-engine / docs /design /CLI_DESIGN.md
ghostdrive1's picture
Upload folder using huggingface_hub
116524e verified
|
Raw
History Blame Contribute Delete
18.1 kB
# CLI & Provider Configuration Design
Design document for the `ace` CLI, model configuration system, and provider registry.
---
## Implementation Status
| Component | Status | Location |
|---|---|---|
| `ModelConfig` / `ACEModelConfig` dataclasses | Done | `ace/providers/config.py` |
| TOML persistence (`ace.toml`) | Done | `ace/providers/config.py` |
| `.env` persistence (secrets) | Done | `ace/providers/config.py` |
| Provider registry (LiteLLM delegation) | Done | `ace/providers/registry.py` |
| Model search / discovery | Done | `ace/providers/registry.py` |
| Connection validation | Done | `ace/providers/registry.py` |
| `ace setup` interactive wizard | Done | `ace/cli/setup.py` |
| `ace models` search command | Done | `ace/cli/setup.py` |
| `ace validate` connection test | Done | `ace/cli/setup.py` |
| Lazy imports (fast CLI startup) | Done | `ace/__init__.py`, `ace/providers/__init__.py`, `ace/providers/registry.py` |
---
## Goals
1. **Zero-friction onboarding** β€” `ace setup` walks the user from nothing to a working config in under a minute.
2. **Secrets / config separation** β€” `ace.toml` (committable, no secrets) + `.env` (gitignored API keys). Teams share model choices without leaking credentials.
3. **Per-role model selection** β€” different models for Agent, Reflector, and Skill Manager to optimise cost vs quality.
4. **Provider agnosticism** β€” any model string LiteLLM supports works. No provider-specific code in the config layer.
5. **Fast CLI startup** β€” heavy dependencies (LiteLLM, instructor, openai) are lazily imported. The `ace` command starts in ~50ms, not ~2s.
---
## Architecture Overview
```
pyproject.toml
[project.scripts]
ace = "ace.cli.setup:main" # entry point
ace/cli/
__init__.py
setup.py # CLI commands + interactive wizard
ace/providers/
__init__.py # lazy re-exports
config.py # ModelConfig, ACEModelConfig, TOML + .env I/O
registry.py # provider detection, model search, connection validation
pydantic_ai.py # resolve_model, settings_from_config (PydanticAI model resolution)
```
The CLI layer (`ace/cli/`) depends on:
- `ace/providers/config.py` β€” always (lightweight, no heavy deps)
- `ace/providers/registry.py` β€” only when validating or searching (imports LiteLLM lazily)
The config layer has **zero heavy dependencies** β€” it uses only `tomllib` (stdlib), `pathlib`, and `dataclasses`.
---
## Configuration Model
### Two-file split
| File | Contains | Git | Written by |
|------|----------|-----|------------|
| `ace.toml` | Model names, temperature, max_tokens, extra_params | Commit | `save_config()` |
| `.env` | `OPENAI_API_KEY=sk-...`, `ANTHROPIC_API_KEY=sk-ant-...` | Gitignore | `save_env_var()` |
### `ace.toml` format
```toml
[default]
model = "gpt-4o-mini"
[agent]
model = "claude-sonnet-4-20250514"
max_tokens = 4096
[reflector]
model = "gpt-4o-mini"
temperature = 0.2
```
Roles without an explicit section inherit from `[default]`. Only non-default values are written (e.g. `temperature` is omitted when it equals `0.0`).
### Config discovery
`find_config(start)` walks up from `start` to the filesystem root looking for `ace.toml`. This supports monorepos where the config lives at the project root but commands run from subdirectories.
### Loading in code
```python
from ace import ACELiteLLM
# Option 1: Load from ace.toml + .env (created by `ace setup`)
ace = ACELiteLLM.from_setup()
# Option 2: Explicit model, keys from environment
ace = ACELiteLLM.from_model("gpt-4o-mini")
# Option 3: Full config object
from ace import ACEModelConfig, ModelConfig
ace = ACELiteLLM.from_config(ACEModelConfig(
default=ModelConfig(model="gpt-4o-mini"),
agent=ModelConfig(model="claude-sonnet-4-20250514"),
))
```
---
## Data Types
### ModelConfig
```python
@dataclass
class ModelConfig:
model: str # LiteLLM model string
temperature: float = 0.0
max_tokens: int = 2048
extra_params: dict[str, Any] | None = None
```
Serialises to/from a TOML section. `to_dict()` omits default values to keep the file clean.
### ACEModelConfig
```python
@dataclass
class ACEModelConfig:
default: ModelConfig # required β€” used as fallback
agent: ModelConfig | None = None # overrides default for Agent role
reflector: ModelConfig | None = None # overrides default for Reflector role
skill_manager: ModelConfig | None = None # overrides default for Skill Manager role
```
`for_role(role)` returns the role-specific config or falls back to `default`.
### ValidationResult
```python
@dataclass
class ValidationResult:
success: bool
model: str = ""
provider: str = ""
latency_ms: int = 0
error: str = ""
```
Returned by `validate_connection()`. On success, includes the provider name and round-trip latency. On failure, includes a human-readable error string.
### ModelInfo
```python
@dataclass
class ModelInfo:
model: str
provider: str
max_input_tokens: int | None = None
max_output_tokens: int | None = None
input_cost_per_m: float | None = None # cost per million tokens
output_cost_per_m: float | None = None
key_found: bool = False # are the required env vars set?
```
Returned by `search_models()`. Pricing is per million tokens (converted from LiteLLM's per-token values).
---
## Provider Registry
All provider logic is delegated to LiteLLM. The registry module (`ace/providers/registry.py`) wraps LiteLLM with a stable API:
| Function | What it does | Makes API calls? |
|----------|-------------|-----------------|
| `get_provider(model)` | Detect provider from model string | No |
| `get_missing_keys(model)` | List required env vars that are unset | No |
| `keys_are_set(model)` | Check if all required env vars exist | No |
| `validate_connection(model, api_key?)` | Send a 3-token test call | Yes (tiny) |
| `search_models(query, provider?, limit?)` | Search LiteLLM's static model registry | No |
| `suggest_models(typo, limit?)` | Fuzzy-match model names for typo correction | No |
### LiteLLM lazy import
LiteLLM takes ~1.5s to import. The registry defers import until first use:
```python
def _litellm():
global _litellm_mod
try:
return _litellm_mod
except NameError:
pass
import litellm as _mod
_litellm_mod = _mod
return _mod
```
All registry functions call `_litellm()` instead of using a top-level import.
### Provider key mapping
`PROVIDER_KEY_ENV` maps provider names to the environment variables they require:
| Provider | Required env vars |
|----------|------------------|
| `openai` | `OPENAI_API_KEY` |
| `anthropic` | `ANTHROPIC_API_KEY` |
| `azure` | `AZURE_API_KEY` |
| `gemini` | `GEMINI_API_KEY` |
| `deepseek` | `DEEPSEEK_API_KEY` |
| `groq` | `GROQ_API_KEY` |
| `bedrock` | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` + `AWS_REGION_NAME` |
| `bedrock_converse` | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` + `AWS_REGION_NAME` |
| `vertex_ai` | `GOOGLE_APPLICATION_CREDENTIALS` |
| `cohere` | `COHERE_API_KEY` |
| `mistral` | `MISTRAL_API_KEY` |
| `openrouter` | `OPENROUTER_API_KEY` |
| `together_ai` | `TOGETHERAI_API_KEY` |
| `fireworks_ai` | `FIREWORKS_AI_API_KEY` |
| `replicate` | `REPLICATE_API_KEY` |
| `huggingface` | `HUGGINGFACE_API_KEY` |
| `perplexity` | `PERPLEXITYAI_API_KEY` |
| `anyscale` | `ANYSCALE_API_KEY` |
For multi-key providers (bedrock), **all** listed vars must be set for `_quick_key_check` to report `True`.
This mapping is preferred over LiteLLM's `validate_environment()` because:
- LiteLLM's response can be inaccurate for certain providers (e.g. bedrock_converse)
- Our mapping is used for both the `Key` column in `ace models` and the key prompting in `ace setup`
---
## CLI Commands
### Entry point
Defined in `pyproject.toml`:
```toml
[project.scripts]
ace = "ace.cli.setup:main"
```
`main()` uses `argparse` with subcommands:
```
ace setup [--dir DIR] Interactive configuration wizard
ace models [QUERY] [--provider P] [--limit N] Search model catalog
ace validate MODEL Test a model connection
ace config Show current configuration
```
### `ace setup`
Interactive wizard flow:
```
1. Load existing .env (if present)
2. Check for existing ace.toml
- If found: show current config, ask "Reconfigure?"
- If declined: return existing config
3. Step 1: Choose your model
- Prompt for model name
- Detect provider via get_provider()
- Validate connection
- If auth fails: prompt for missing keys, retry
- If model not found: suggest alternatives, re-prompt
- If success: continue
4. Step 2: Role assignment
- Ask "Use this model for all roles?" (default: yes)
- If no: prompt for each role (Agent, Reflector, Skill Manager)
- Enter = keep default (skip validation)
- Different model = validate it
5. Save ace.toml and .env
6. Print configuration summary
```
Key behaviours:
- **Validation-first**: the wizard tries the connection immediately. If credentials exist in the environment (env vars, `.env`, AWS profiles), no prompting needed.
- **Error recovery**: on failed validation, env vars set during prompting are rolled back.
- **Secret handling**: API keys are prompted via `getpass` (hidden input). Non-secret values like `AWS_REGION_NAME` and `GOOGLE_APPLICATION_CREDENTIALS` use regular `input()` so users can see what they type.
- **Typo correction**: when a model is not found, `suggest_models()` offers alternatives.
### `ace models`
Searches LiteLLM's static `model_cost` registry (no API calls):
```
$ ace models claude haiku
Model Provider Input $/M Output $/M Key
------------------------------------------------------------------------------------------
claude-haiku-4-5-20251001 anthropic $1.00 $5.00 βœ“
us.anthropic.claude-haiku-4-5-20251001-v1:0 bedrock_converse $1.10 $5.50 βœ—
```
- Multiple query terms are AND-matched (all must appear in the model name)
- `--provider` filters by LiteLLM provider name
- `--limit` caps results (default 20), shows total count
- `Key` column uses `_quick_key_check()` β€” checks env vars only, no API calls
### `ace validate`
Sends a minimal LLM call (3 tokens: "Say 'ok'") to verify:
- API key authentication
- Model availability at the provider
- Network connectivity
```
$ ace validate gpt-4o-mini
βœ“ Connected! (gpt-4o-mini via openai, 203ms)
```
On failure, suggests similar model names if the model wasn't found.
All subcommands (`models`, `validate`, `config`) use `_load_project_dotenv()` which finds `.env` relative to `ace.toml` (via `find_config()`), not just CWD.
### `ace config`
Displays the current configuration from `ace.toml`:
```
$ ace config
Configuration (/path/to/ace.toml)
Role Model
---------------- ---------------------------------------------
default gpt-4o-mini
agent claude-sonnet-4-20250514
reflector (default)
skill_manager (default)
```
Uses `find_config()` to locate `ace.toml` from the current directory upward.
### `ace models` (no query)
When called without arguments, shows usage examples instead of dumping arbitrary results:
```
$ ace models
Usage: ace models <query>
Examples:
ace models claude All Claude models
ace models gpt 4o GPT-4o variants
ace models haiku us US-region Haiku models
ace models --provider openai All OpenAI models
```
---
## Kayba CLI
The `kayba` entry point (`ace.cli:main`) provides the hosted API client. It wraps trace management, pipeline execution, insight triage, prompt generation, and integration management. See [Hosted API docs](../integrations/hosted-api.md) for the full reference.
```toml
[project.scripts]
ace = "ace.cli.setup:main"
kayba = "ace.cli:main"
ace-mcp = "ace.integrations.mcp.server:main"
```
Key command groups: `traces`, `run`, `insights`, `prompts`, `integrations`, `status`, `materialize`, `batch`, `setup`.
---
## Lazy Import Strategy
### Problem
`import ace` eagerly imported all submodules, including `litellm` (~1.5s). This made the CLI unusable (~2s startup for a simple `ace --help`).
### Solution
Three-layer lazy import:
1. **`ace/__init__.py`** β€” `__getattr__`-based lazy loading. `TYPE_CHECKING` block for IDE support, `_LAZY_IMPORTS` dict for runtime.
2. **`ace/providers/__init__.py`** β€” same pattern. Config imports are eager (lightweight), everything else is lazy.
3. **`ace/providers/registry.py`** β€” `_litellm()` helper defers `import litellm` until first function call.
Result: `ace --help` runs in ~50ms. LiteLLM is only imported when the user actually searches, validates, or sets up.
### Pattern
```python
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .heavy_module import HeavyClass # IDE autocomplete
_LAZY_IMPORTS = {
"HeavyClass": ("package.heavy_module", "HeavyClass"),
}
def __getattr__(name: str) -> object:
if name in _LAZY_IMPORTS:
module_path, attr = _LAZY_IMPORTS[name]
import importlib
module = importlib.import_module(module_path)
value = getattr(module, attr)
globals()[name] = value # cache for subsequent access
return value
raise AttributeError(...)
```
---
## File Map
```
ace/
__init__.py # lazy re-exports for all ace symbols
cli/
setup.py # main(), run_setup(), _cmd_models(), _cmd_validate(), _cmd_config()
providers/
__init__.py # lazy re-exports (config eager, rest lazy)
config.py # ModelConfig, ACEModelConfig, TOML/env I/O
registry.py # provider detection, model search, validation
pydantic_ai.py # resolve_model, settings_from_config (PydanticAI model resolution)
```
Generated files:
```
project-root/
ace.toml # model config (commit this)
.env # API keys (gitignore this)
```
---
## Known Issues
### Resolved
| # | Issue | Fix |
|---|-------|-----|
| 1 | `_prompt_secret` used `getpass` for non-secrets like `AWS_REGION_NAME` | Non-secret vars (`AWS_REGION_NAME`, `GOOGLE_APPLICATION_CREDENTIALS`) now use visible `_prompt()` |
| 2 | Error classification used fragile substring matching | Simplified: only "not found" is non-recoverable; everything else offers key prompting |
| 3 | `save_env_var` didn't quote values | Values now written as `KEY="value"` |
| 4 | `_PROVIDER_KEY_ENV` was private but imported externally | Renamed to `PROVIDER_KEY_ENV` (public) |
| 5 | `v` / `x` status symbols | Replaced with `βœ“` / `βœ—` |
| 7 | `ace models` with no query dumped arbitrary results | Now shows usage examples instead |
| 8 | No way to inspect current config | Added `ace config` command |
| 10 | `ace validate` / `ace models` only loaded `.env` from CWD | Added `_load_project_dotenv()` β€” finds `.env` relative to `ace.toml` via `find_config()` |
| 11 | Unused imports `asdict`, `field` in `config.py` | Removed |
| 12 | Dead function `_litellm_available()` in `registry.py` | Removed |
| 13 | Unused `PROVIDER_MODEL_EXAMPLES` import in `setup.py` | Removed |
| 14 | Duplicate `search_models` import in `setup.py` | Consolidated to single top-level import |
### Open
| # | Issue | Impact |
|---|-------|--------|
| 6 | No `--non-interactive` mode for CI/Docker | Blocks CI automation β€” requires a future `ace setup --model MODEL --skip-validation` flag |
| 9 | Per-role model selection skips validation when keeping default | Low β€” the default was already validated in Step 1 |
| 15 | No multi-provider key setup in one pass | When reconfiguring (`ace setup` on an existing config), users who want different providers per role (e.g. OpenAI default + Anthropic agent + Bedrock reflector) must go through each role sequentially β€” keys are only prompted when a model fails validation. A future improvement should let users configure all providers and their keys upfront in a single credentials step, before role assignment begins. |
---
## Design Decisions
### Why hand-rolled TOML serialisation?
Python's `tomllib` (stdlib since 3.11) only reads TOML. Writing requires `tomli-w` (third-party) or a manual serialiser. To avoid adding a dependency for a simple four-section config file, we hand-roll `_to_toml()`. The format is simple enough that this is reliable β€” the only complex case is `extra_params` (inline table).
### Why our own key mapping instead of LiteLLM's?
LiteLLM's `validate_environment()` sometimes returns incorrect keys (e.g. suggesting `AWS_BEARER_TOKEN_BEDROCK` for bedrock_converse when the standard auth is `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` + `AWS_REGION_NAME`). Our `PROVIDER_KEY_ENV` mapping is simpler, auditable, and covers the common providers. For unknown providers, we fall back to LiteLLM's response or guess `{PROVIDER}_API_KEY`.
### Why validation-first in setup?
Many users already have credentials in their environment (exported vars, AWS profiles, `.env` from another project). Prompting for keys before trying would waste their time. By attempting the connection first, the happy path is: type model name β†’ instant success β†’ done.
### Why `ace.toml` instead of `pyproject.toml [tool.ace]`?
- Keeps ACE config decoupled from the Python project (ACE might be used in non-Python contexts)
- `find_config()` can walk up the directory tree independently
- Easier to reason about β€” one file, one purpose