feat: per-user LLM provider configuration with encrypted API-key storage #333

Closed
opened 2026-06-28 22:10:28 +00:00 by james · 0 comments
Owner

Let each user configure the provider + model + API key the built-in agent will use, stored per-user, encrypted at rest — the prerequisite (ADR-0029 §1 "pluggable provider" + §5 "per-user keys, encrypted at rest") that the agent-loop / chat-UI tickets consume. Part of epic #47.

This ticket is config + storage + crypto + settings UI only. The actual LlmProvider adapters and the agent loop (inference, SSE, conversations) are deferred to the agent-loop ticket, which reads the decrypted key via a server-only helper this ticket provides.

Scope

  • Crypto helper (apps/api/lib/crypto/) — AES-256-GCM encrypt/decrypt keyed by a new env var CAROL_ENCRYPTION_KEY (32-byte key, base64/hex). Random IV per op; persist iv‖tag‖ciphertext. Validate the env var on use (not at boot) with a clear error if missing/malformed. Add CAROL_ENCRYPTION_KEY to the README Configuration table.
  • Migration 024_user_llm_credentials: user_id (pk, FK users.id cascade), provider (anthropic | openai_compatible), model (text), base_url (text null — for openai_compatible incl. local Ollama), api_key_ciphertext (text null — null for keyless local), created_at, updated_at. Schema + entity + repository (get/upsert/delete; ciphertext never leaves the repo except via an internal decrypt helper).
  • DTOs (zod) — response { provider, model, baseUrl, keyConfigured: boolean } (never the key); request { provider, model, baseUrl?, apiKey? } (apiKey write-only — set encrypts + stores; omitted on update keeps the existing key; explicit clear removes it).
  • API routes (per-user, session or PAT): GET /api/settings/llm (config DTO + keyConfigured), PUT /api/settings/llm (set), DELETE /api/settings/llm (clear). Plus a server-only getDecryptedLlmKey(userId) for the future agent loop (not exposed via the API). Validate provider/model; require a base URL for openai_compatible (allow http for localhost/private, https otherwise).
  • Client — an "AI provider" card on the account screen (each user configures their own): provider picker (Anthropic / OpenAI-compatible), model (sensible default per provider), base-URL field (shown for OpenAI-compatible), write-only API-key field showing configured / set new, save + clear. @carol/api-client hooks; i18n account.llm.* in Carol's voice.
  • Tests (both engines): crypto round-trip; storage upsert/get/delete; the key is never returned in any DTO; per-user scoping; missing CAROL_ENCRYPTION_KEY → clear error. OpenAPI for the new routes; api-client regen.

Acceptance criteria

  • A user can set provider + model + base URL + key; the key is AES-256-GCM-encrypted at rest and never returned by the API (only keyConfigured).
  • getDecryptedLlmKey(userId) returns the plaintext server-side for the future loop; local (no-key) configs are supported.
  • Per-user isolation; both-engine tests; OpenAPI + drift gate green.
  • CAROL_ENCRYPTION_KEY documented in the README.

Out of scope

  • The LlmProvider adapters + inference and the agent loop / SSE / conversations (next ticket consumes this). Voice. A shared instance-level fallback key (ADR-0029 notes there is none by default).

Implements ADR-0029. Part of epic #47.

Let each user configure the provider + model + API key the built-in agent will use, stored **per-user, encrypted at rest** — the prerequisite (ADR-0029 §1 "pluggable provider" + §5 "per-user keys, encrypted at rest") that the agent-loop / chat-UI tickets consume. Part of epic #47. This ticket is **config + storage + crypto + settings UI only**. The actual `LlmProvider` adapters and the agent loop (inference, SSE, conversations) are deferred to the agent-loop ticket, which reads the decrypted key via a server-only helper this ticket provides. ## Scope - **Crypto helper** (`apps/api/lib/crypto/`) — AES-256-GCM `encrypt`/`decrypt` keyed by a new env var **`CAROL_ENCRYPTION_KEY`** (32-byte key, base64/hex). Random IV per op; persist iv‖tag‖ciphertext. Validate the env var **on use** (not at boot) with a clear error if missing/malformed. Add `CAROL_ENCRYPTION_KEY` to the README Configuration table. - **Migration** `024_user_llm_credentials`: `user_id` (pk, FK users.id cascade), `provider` (`anthropic` | `openai_compatible`), `model` (text), `base_url` (text null — for `openai_compatible` incl. local Ollama), `api_key_ciphertext` (text null — null for keyless local), `created_at`, `updated_at`. Schema + entity + repository (get/upsert/delete; ciphertext never leaves the repo except via an internal decrypt helper). - **DTOs (zod)** — response `{ provider, model, baseUrl, keyConfigured: boolean }` (**never the key**); request `{ provider, model, baseUrl?, apiKey? }` (apiKey write-only — set encrypts + stores; omitted on update keeps the existing key; explicit clear removes it). - **API routes** (per-user, session or PAT): `GET /api/settings/llm` (config DTO + `keyConfigured`), `PUT /api/settings/llm` (set), `DELETE /api/settings/llm` (clear). Plus a **server-only** `getDecryptedLlmKey(userId)` for the future agent loop (not exposed via the API). Validate provider/model; require a base URL for `openai_compatible` (allow http for localhost/private, https otherwise). - **Client** — an "AI provider" card on the account screen (each user configures their own): provider picker (Anthropic / OpenAI-compatible), model (sensible default per provider), base-URL field (shown for OpenAI-compatible), write-only API-key field showing *configured / set new*, save + clear. `@carol/api-client` hooks; i18n `account.llm.*` in Carol's voice. - **Tests** (both engines): crypto round-trip; storage upsert/get/delete; the key is never returned in any DTO; per-user scoping; missing `CAROL_ENCRYPTION_KEY` → clear error. OpenAPI for the new routes; api-client regen. ## Acceptance criteria - [ ] A user can set provider + model + base URL + key; the key is AES-256-GCM-encrypted at rest and **never returned** by the API (only `keyConfigured`). - [ ] `getDecryptedLlmKey(userId)` returns the plaintext server-side for the future loop; local (no-key) configs are supported. - [ ] Per-user isolation; both-engine tests; OpenAPI + drift gate green. - [ ] `CAROL_ENCRYPTION_KEY` documented in the README. ## Out of scope - The `LlmProvider` adapters + inference and the agent loop / SSE / conversations (next ticket consumes this). Voice. A shared instance-level fallback key (ADR-0029 notes there is none by default). Implements ADR-0029. Part of epic #47.
james closed this issue 2026-06-28 23:29:51 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#333
No description provided.