ADR-platform-003
Layered LLM config resolution with CLI and env var fallback
Context
The LLM config system had a single path: profiles.json at ~/.agentcanvas/, managed only through the web UI's SettingsModal. No CLI existed for headless/SSH/Docker/CI environments. The .env file documented VLM_API_KEY etc. but the profile system silently ignored them. ProfileStore cached data in memory with no invalidation, so CLI changes were invisible to a running server. The @lru_cache on get_settings() made runtime config overrides fragile.
Decision
(1) Layered config resolution with field-level fallback: profile fields > env vars (AGENTCANVAS_* primary, VLM_* legacy alias) > registry defaults. Env vars fill empty profile fields rather than replacing whole profiles. (2) Standalone argparse CLI (cli.py) that directly imports ProfileStore β works without the FastAPI server. 8 subcommands: list/show/set/delete/activate/test/providers/env. (3) Mtime-based cache invalidation (TTL 2s) as primary fix for inter-process CLIβserver coherence, with threading.RLock as secondary intra-process safety. (4) Replace @lru_cache on get_settings() with explicit singleton. (5) Sync + async API key validation (key_validator.py). (6) schema_version field in profiles.json for future migration. (7) Interactive setup script (setup_llm.sh) with 8-check automated verification.
Alternatives
(a) typer + TOML config rewrite β rejected: 3 new dependencies, TOML migration breaks existing profiles.json, Python 3.8 typer support uncertain, overkill for 8 commands. (b) pydantic-settings custom SettingsSource β rejected: named-profile semantics (multi-profile with activation) don't map to flat Settings, despite pydantic-settings v2 being available. (c) Env vars as whole-profile tier (level 3 in precedence) β rejected after Architect review: an active profile with empty api_key would block env var access, breaking the headless use case. Field-level fallback chosen instead.
Rationale
Field-level env var fallback serves both the "explicit profile wins" principle and the "headless just works" principle. The CLI sharing ProfileStore via the filesystem (not REST API) is simpler and works offline. Zero new pip dependencies (argparse + threading are stdlib; httpx already a dep). The ralplan consensus process (Planner β Architect β Critic, 2 iterations) caught the field-level vs whole-profile distinction and the mtime-vs-Lock priority framing before implementation.
Affected docs
architecture.md (config resolution), roadmap.md (TODO #30 partial)