2026-05-10
ADR-platform-006
Env-only LLM credentials; profiles.json is git-safe config
- Date
- 2026-05-10
- Status
- accepted
**Supersedes**: ADR-platform-003 (credential-resolution policy only β CLI / mtime-invalidation / schema_version mechanisms preserved)
## Context
ADR-platform-003 introduced layered config resolution where `profile.api_key` was the primary credential source and env vars were a field-level fallback (`AGENTCANVAS_API_KEY` / `VLM_API_KEY`). In practice:
- Users saved API keys via the SettingsModal UI, which wrote them into `~/.agentcanvas/profiles.json` (chmod 0o600).
- `_fill_from_env` only filled *empty* profile fields, so any populated `profile.api_key` permanently shadowed env vars.
- Over time profiles accumulated stale keys that no longer matched the user's current shell env.
A 2026-05-10 incident: DiscussNav cost analysis revealed OpenAI billing was being charged to project A (`sk-proj-yrxc...` in profiles.json) while the user's intended billing project B (`sk-proj-ofqr...` in `OPENAI_API_KEY` shell env) had idle balance. The user had set `export OPENAI_API_KEY=...` expecting it to be authoritative, per the convention every other LLM tool in the ecosystem (OpenAI SDK, Anthropic SDK, litellm, langchain, aider, cursor, claude-code, codex) follows.
Profiles.json living in `~/.agentcanvas/` (outside the repo) compounded the problem: the file couldn't enter version control or dotfile sync without leaking secrets, multi-machine setups required manual key sync, and the file's existence was invisible to project-level tooling.
## Decision
1. **`LLMProfile` no longer carries `api_key`.** The dataclass field is removed. Loaders silently strip legacy `api_key` from existing profiles.json files (one-time migration on next save).
2. **Per-provider standard env var.** `ProviderDef` gains an `env_var` field naming the de-facto standard env var for each provider:
- `openai` β `OPENAI_API_KEY`
- `anthropic` β `ANTHROPIC_API_KEY`
- `google` β `GEMINI_API_KEY`
- `moonshot` β `MOONSHOT_API_KEY`
- `mistral` β `MISTRAL_API_KEY`
- `xai` β `XAI_API_KEY`
- `deepseek` β `DEEPSEEK_API_KEY`
- `huggingface` β `HF_TOKEN`
- β¦23 entries total, matching litellm's internal convention so explicit-key and litellm-default resolution agree.
- `ollama` β `""` (no key needed)
3. **`get_provider_api_key(provider_id)` is the single source of truth.** Reads `os.environ.get(reg.env_var)`. Used by `resolve_provider_config()`, `api/platform/profiles.py:_profile_to_dict` (for the `api_key_set` UI flag), and the model-discovery endpoint.
4. **Private fallbacks retired.** `AGENTCANVAS_API_KEY` / `VLM_API_KEY` aliases removed from `_fill_from_env` and `_env_only_config`. `custom` provider keeps `AGENTCANVAS_API_KEY` as its env var only because it has no vendor-standard name.
5. **profiles.json moved into the repo.** Default location switched from `~/.agentcanvas/profiles.json` to `
/agentcanvas/backend/profiles.json` (alongside `requirements.txt`). Override available via `AGENTCANVAS_PROFILES_FILE` env var. File permission relaxed from 0o600 to default umask (file is now key-free and committable). 6. **CLI updated.** `python -m app.llm.cli set` lost `--api-key`; new `cli env` subcommand lists all provider env vars with a β/Β· status indicator. `cli show ` displays the provider's `env_var` name. 7. **API request bodies updated.** `CreateProfileBody` / `UpdateProfileBody` lost the `api_key` field. `BatchUpsertBody.keys` accepted but ignored (back-compat for older frontends). The frontend's `LLMProfile` type only ever exposed `api_key_set: boolean`, so no UI breakage. ## Alternatives a. **Keep `profile.api_key`, flip precedence so env wins.** Rejected: the UI's "set API key" interaction continues to write into the profile, recreating the same drift. The precedence flip surprises users who previously relied on the UI as authoritative. b. **Profile is the single source of truth, drop env vars entirely.** Rejected: profiles.json then must stay outside git (security), can't sync across machines without manual copy, and breaks the universal "export `OPENAI_API_KEY`" muscle memory every developer already has from other tools. c. **Hybrid: per-profile per-environment binding.** Rejected: introduces a per-profile `env_var` override that solves a problem nobody has β every provider already has a single canonical env var name in industry practice. d. **OS keychain / 1Password CLI integration.** Out of scope: complementary, not alternative. Users who want keychain β env injection can do that in `.bashrc`; the env-only contract stays the same. ## Rationale - **Single source of truth eliminates the shadowing-bug class.** With profileβenv field-level fallback, the precedence depended on whether profile.api_key was empty, which depended on UI history, which was effectively a hidden state machine. Pulling the field entirely makes "where does the key come from" trivially answerable: from `os.environ[reg.env_var]`, always. - **Convention alignment with the broader ecosystem.** litellm's auto-resolution, OpenAI SDK's `OPENAI_API_KEY` default, Anthropic's `ANTHROPIC_API_KEY` default, and every CLI tool in the LLM space converge on the same env var names. Aligning saves users from re-learning a project-specific config layer. - **profiles.json becomes safe to commit.** Git, dotfile syncs, screenshots, IDE remote-pair sessions β none of these leak secrets anymore. The file becomes a reviewable artefact of the deployment's provider/model choices. - **CLI / mtime-invalidation / RLock / schema_version mechanisms from ADR-platform-003 are preserved.** This ADR scopes only the credential-resolution policy change. The rest of the LLM config plumbing keeps working. ## Affected docs - `decisions/platform/adr-platform-003` β superseded (credential-resolution policy only; supporting infra preserved) - `architecture.md` β config resolution section updated - `roadmap.md` β credential-related TODOs (if any) closed - `agentcanvas/backend/profiles.json` β new committed default - Field
platform
- Supersedes
ADR-platform-003