You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Feature: admin-managed LLM API keys with sequential fallback
Today OPENAI_API_KEY is a single env var baked into the container image. There's no way to rotate without redeploy, no way to fall back when a key gets rate-limited, and no way for a non-operator to manage credentials. This issue replaces the env-var model with an admin-managed key pool backed by Postgres, with ordered fallback so a single rate-limited key doesn't break the app.
Goals
Role on user — role: 'admin' | 'user', default 'user', NOT NULL. Bootstrap path is a single env var set at deploy time (no manual SQL).
Admin dashboard — /admin/keys (or under settings) lists, adds, edits, enables/disables, deletes LLM keys. Per-key provider + apiKey + optional base_url + priority + enabled. UI never sees plaintext key after creation (only last4 for display).
Sequential fallback — the backend tries keys in priority ASC order; on a retryable error (rate-limit / 5xx / network) it falls back to the next enabled key. Retryable-error classification lives in one place.
Self-host-first — no new service. The key pool lives in our existing Postgres. Keys encrypted at rest with an env-supplied KEK. Single VPS continues to be enough.
Health visibility — the dashboard shows last_used_at, last_success_at, last_error_at, last_error_message per key, plus a "test" button that pings with that key. Operators can spot a dead key without reading logs.
Graceful migration — when the llm_key table is empty, fall back to OPENAI_API_KEY env (preserves today's single-env-var setup so existing deployments don't break).
Out of scope
BYOK (bring your own key) per end-user — admins configure org-wide keys only.
Multi-provider in MVP — schema supports it via the provider column, but only openai is implemented in this issue. Anthropic / Gemini / etc. are follow-ups.
Usage metering / cost attribution — separate issue.
Auto-discovery of free-tier rotation / quota refresh timers.
LiteLLM / Portkey as a sidecar — they're good products but adding another container is overkill for a single-provider pool. Revisit if we go multi-provider.
Migration adds the enum + the column. The default makes it backward-compatible — every existing user becomes 'user'.
Bootstrap the first admin
INITIAL_ADMIN_EMAIL env var. On every login / session read, if the env is set AND the user has role = 'user' AND their email matches (case-insensitive), promote to 'admin'. Idempotent — safe to leave the env set forever. Document in .env.example + docs/AUTH.md.
After the first admin exists, only admins can promote others (via the dashboard — there's a "promote user to admin" action on the existing user list, gated by requireAdmin()).
llm_key table
New module lib/admin/schema.ts (mirrors lib/threads/, lib/memory/):
exportconstllmProvider=pgEnum("llm_provider",["openai"]);// extend laterexportconstllmKey=pgTable("llm_key",{id: text("id").primaryKey(),// nanoid, 12 charslabel: text("label").notNull(),// "Prod key #1"provider: llmProvider("provider").notNull().default("openai"),// AES-GCM ciphertext; never returned to client in plaintext.encryptedKey: text("encrypted_key").notNull(),iv: text("iv").notNull(),// AES-GCM noncekeyLast4: text("key_last4").notNull(),// for UI; computed at createbaseUrl: text("base_url"),// optional override of OPENAI_BASE_URLenabled: boolean("enabled").notNull().default(true),priority: integer("priority").notNull().default(0),// lower = tried firstcreatedByUserId: text("created_by_user_id").references(()=>user.id),createdAt: timestamp("created_at").defaultNow().notNull(),updatedAt: timestamp("updated_at").defaultNow().notNull(),lastUsedAt: timestamp("last_used_at"),lastSuccessAt: timestamp("last_success_at"),lastErrorAt: timestamp("last_error_at"),lastErrorMessage: text("last_error_message"),},(t)=>[index("llm_key_priority_idx").on(t.priority),index("llm_key_enabled_idx").on(t.enabled),]);
userId ON DELETE SET NULL on created_by_user_id so removing the creator doesn't cascade-delete the key.
Update docs/DB.md in the same commit (rule #1 spirit).
Encryption at rest
Keys are encrypted with AES-256-GCM using a server-only KEK in env (LLM_KEY_ENCRYPTION_KEY, 32 random bytes hex). Required at startup; the app refuses to boot without it (or refuses to start the admin routes — make the failure explicit, not silent).
IV: 12 random bytes per row, stored alongside ciphertext.
Auth tag: included in encryptedKey (GCM packs it).
Never decrypt on the client. The list / detail endpoints return keyLast4 only.
Re-encryption: if the KEK ever rotates, a one-shot script re-encrypts all rows. Out of scope for this issue, but the encryption helper should be parameterized by KEK from day one.
API surface (new routes, all withAuth + new requireAdmin)
Route
Method
Auth
Notes
/api/admin/keys
GET
admin
List keys (excludes encryptedKey + iv; returns keyLast4 only).
Replace the two ChatOpenAI singletons with a chatOpenAIWithFallback(opts) helper:
asyncfunctionchatOpenAIWithFallback<T>(opts: ChatOpenAIFields,invoke: (model: ChatOpenAI)=>Promise<T>,): Promise<T>{constkeys=awaitgetActiveLlmKeys();// cache 30s, fall back to env when table emptyletlastErr: unknown;for(constkofkeys){try{constmodel=makeChatOpenAI({
...opts,apiKey: decrypt(k.encryptedKey,k.iv),configuration: {baseURL: k.baseUrl??undefined},});constout=awaitinvoke(model);awaitmarkKeySuccess(k.id);returnout;}catch(err){if(!isRetryable(err))throwerr;// 4xx (non-429), bad-prompt, tool errors → baillastErr=err;awaitmarkKeyError(k.id,err);// continue to next key}}throwlastErr??newError("no LLM keys available");}
isRetryable(err) returns true for: HTTP 429, 5xx, network errors, ETIMEDOUT, ECONNRESET, DNS failures. false for 400 (bad request), 401 (bad key — non-retryable, key is dead), 422 (validation), etc. Centralized so all sub-agents benefit.
getActiveLlmKeys():
If table has any enabled rows: return them ordered by priority ASC.
Else: return a synthetic single-key array from process.env.OPENAI_API_KEY (so existing deployments work unchanged). Log a one-line warning the first time the fallback path is hit per process.
Caching: in-process LRU keyed by (process.pid, "llm_keys"), 30s TTL. Avoid DB hammering on every chat turn.
triggerBackgroundAgentNode already uses runs.create — it doesn't construct a ChatOpenAI itself, so no change there. Only the per-sub-agent ChatOpenAI constructions need the wrapper.
The two compiled graphs (agent + background_agent) — the background agent doesn't call LLMs (summarize uses store, not model). So the fallback wrapper only touches the four sub-agents' call-model-node.ts. Verify.
Admin dashboard UI
New route app/admin/keys/page.tsx, server component guarded by requireAdmin (server-side redirect to /login if not signed in, to / with toast if not admin).
UI sections (mirror the existing Memory tab's chrome — same bg-muted/30 shell, same shadcn primitives per rule #11):
Stat card — total keys / enabled / last-error-in-last-hour. Reuse the aggregate.ts pattern from observability.
Keys table — columns: label, provider, keyLast4, baseUrl, priority, enabled toggle, last used, last success / last error (with hover for full error message), actions (edit / rotate / test / delete).
Add key — form: label, provider (dropdown, only "openai" for now), apiKey (password input — never sent back), baseUrl (optional), priority (number, default 0).
Empty state — "No keys configured. Add one to start using the admin-managed pool. Until then, the app will use OPENAI_API_KEY from env."
The "Users" sub-tab (under /admin/users) reuses components/settings/memory-view.tsx's table pattern — same skeleton, different data.
XSS / security
apiKey field on the create form: <input type="password"> + autocomplete="off" + never echoed back via React state (one-shot, dispatched on submit).
Display only keyLast4 in the list — never the full key.
Rotate action: replace the existing key with a new one; the old ciphertext is overwritten in the same transaction.
Delete action: hard delete the row + its ciphertext (no soft delete — there is no recovery path for an API key).
Acceptance criteria
New user.role column with default 'user'; existing users unchanged.
INITIAL_ADMIN_EMAIL env var promotes matching users to admin on login.
Admin can add / edit / enable / disable / delete / rotate keys from /admin/keys.
Backend uses the key pool in priority ASC order with sequential fallback on retryable errors.
Empty llm_key table → app falls back to OPENAI_API_KEY env (today's behavior).
Non-admin users hitting /admin/* get 403 (or redirect to / with toast).
API keys are AES-256-GCM encrypted at rest; keyLast4 is the only field returned in any list / detail.
last_used_at, last_success_at, last_error_at, last_error_message updated on every chat invocation.
pnpm lint, pnpm typecheck, pnpm test green.
No plaintext apiKey / encryptedKey ever appears in any log, span, or response payload. The existing FORBIDDEN regex in bulkInsertSpans already redacts api[_-]?key — verify with a span assertion test that the new key-handling path doesn't bypass it.
tests/backend/model/fallback.test.ts — mock the S3… sorry, the OpenAI client; force key A to 429 once, assert key B is tried next and the result is returned from B. Force key A to 401 (non-retryable) — assert no fallback, error propagates. RED → GREEN.
tests/backend/model/fallback.test.ts — empty llm_key table, process.env.OPENAI_API_KEY set; assert the synthetic single-key fallback works. RED → GREEN.
tests/api/admin/bootstrap.test.ts — INITIAL_ADMIN_EMAIL="alice@example.com", user with that email logs in, user.role === 'admin' after. Set the env to a different email — assert no promotion. RED → GREEN.
tests/frontend/admin/keys.test.tsx — render the keys page with 2 mocked keys; assert keyLast4 displayed, no full key visible; click "test" → loading state → success / error state. RED → GREEN.
tests/observability/redaction.test.ts — assert a chat run that goes through a key-pool path produces spans whose meta.llm_kwargs does NOT contain apiKey, OPENAI_API_KEY, or the decrypted plaintext. (Carry-over from the existing redaction contract — see bulkInsertSpansFORBIDDEN regex.)
Notes for the implementer
Single key for the duration of a chat run. The fallback happens at the request boundary — a streaming response that started with key A won't swap to key B mid-stream. This avoids partial-state confusion in the chat UI.
Don't cache the decrypted key in module scope. Always decrypt on demand inside the loop; the encrypted-at-rest invariant is the whole point.
Don't write your own AES wrapper — Node's built-in crypto.createCipheriv('aes-256-gcm', ...) is fine. No new dep.
Rate-limit budget: a misbehaving admin who adds 100 keys doesn't make the app 100× slower — the cache makes the DB read cheap. But the chat invocation will try up to N keys on a single bad request. Set maxAttempts = keys.length and don't add a hard cap (the pool is small by design).
Self-promotion guard: an admin can promote others, but at least one admin must remain. Server-side check before allowing self-demotion or demotion of the only admin.
Audit log: every admin action (create / rotate / delete / role change) appends a row to a new admin_audit_log table for forensics. Out of scope for MVP — flag in follow-up.
Related
backend/model.ts — the two ChatOpenAI singletons this issue replaces.
lib/auth/with-auth.ts — the withAuth wrapper that requireAdmin builds on.
lib/memory/merge.ts + backend/store.ts — existing module pattern to mirror for lib/admin/.
lib/observability/callback-collector.ts — existing redaction that must continue to cover the new key-pool path (see FORBIDDEN regex in bulkInsertSpans).
docs/AUTH.md — operator guide; gets the new env var + role mechanics.
docs/DB.md — schema conventions; new tables follow the same shape.
Feature: admin-managed LLM API keys with sequential fallback
Today
OPENAI_API_KEYis a single env var baked into the container image. There's no way to rotate without redeploy, no way to fall back when a key gets rate-limited, and no way for a non-operator to manage credentials. This issue replaces the env-var model with an admin-managed key pool backed by Postgres, with ordered fallback so a single rate-limited key doesn't break the app.Goals
user—role: 'admin' | 'user', default'user', NOT NULL. Bootstrap path is a single env var set at deploy time (no manual SQL)./admin/keys(or under settings) lists, adds, edits, enables/disables, deletes LLM keys. Per-keyprovider+apiKey+ optionalbase_url+priority+enabled. UI never sees plaintext key after creation (onlylast4for display).priority ASCorder; on a retryable error (rate-limit / 5xx / network) it falls back to the next enabled key. Retryable-error classification lives in one place.last_used_at,last_success_at,last_error_at,last_error_messageper key, plus a "test" button that pings with that key. Operators can spot a dead key without reading logs.llm_keytable is empty, fall back toOPENAI_API_KEYenv (preserves today's single-env-var setup so existing deployments don't break).Out of scope
providercolumn, but onlyopenaiis implemented in this issue. Anthropic / Gemini / etc. are follow-ups.Data model
user.roleMigration adds the enum + the column. The default makes it backward-compatible — every existing user becomes
'user'.Bootstrap the first admin
INITIAL_ADMIN_EMAILenv var. On every login / session read, if the env is set AND the user hasrole = 'user'AND their email matches (case-insensitive), promote to'admin'. Idempotent — safe to leave the env set forever. Document in.env.example+docs/AUTH.md.After the first admin exists, only admins can promote others (via the dashboard — there's a "promote user to admin" action on the existing user list, gated by
requireAdmin()).llm_keytableNew module
lib/admin/schema.ts(mirrorslib/threads/,lib/memory/):userId ON DELETE SET NULLoncreated_by_user_idso removing the creator doesn't cascade-delete the key.Update
docs/DB.mdin the same commit (rule #1 spirit).Encryption at rest
Keys are encrypted with AES-256-GCM using a server-only KEK in env (
LLM_KEY_ENCRYPTION_KEY, 32 random bytes hex). Required at startup; the app refuses to boot without it (or refuses to start the admin routes — make the failure explicit, not silent).encryptedKey(GCM packs it).keyLast4only.API surface (new routes, all
withAuth+ newrequireAdmin)/api/admin/keysencryptedKey+iv; returnskeyLast4only)./api/admin/keys{ label, provider, apiKey, baseUrl?, priority? }./api/admin/keys/[id]apiKeyhere./api/admin/keys/[id]/api/admin/keys/[id]/rotateencryptedKeywith a newapiKey./api/admin/keys/[id]/testlast_success_at/last_error_*./api/admin/usersrole. (Read-only.)/api/admin/users/[id]role. Self-demotion blocked (you can't demote the only admin).requireAdminlives atlib/auth/require-admin.ts— wrapswithAuth, throws 403 ifuser.role !== 'admin'.Rule #9: every route wrapped in
withAuth. NewrequireAdminis layered on top.Update
docs/APIS.mdin the same commit (rule #1).Backend —
backend/model.tsrewriteReplace the two
ChatOpenAIsingletons with achatOpenAIWithFallback(opts)helper:isRetryable(err)returnstruefor: HTTP 429, 5xx, network errors, ETIMEDOUT, ECONNRESET, DNS failures.falsefor 400 (bad request), 401 (bad key — non-retryable, key is dead), 422 (validation), etc. Centralized so all sub-agents benefit.getActiveLlmKeys():priority ASC.process.env.OPENAI_API_KEY(so existing deployments work unchanged). Log a one-line warning the first time the fallback path is hit per process.Caching: in-process LRU keyed by
(process.pid, "llm_keys"), 30s TTL. Avoid DB hammering on every chat turn.triggerBackgroundAgentNodealready usesruns.create— it doesn't construct a ChatOpenAI itself, so no change there. Only the per-sub-agentChatOpenAIconstructions need the wrapper.The two compiled graphs (
agent+background_agent) — the background agent doesn't call LLMs (summarizeuses store, not model). So the fallback wrapper only touches the four sub-agents'call-model-node.ts. Verify.Admin dashboard UI
New route
app/admin/keys/page.tsx, server component guarded byrequireAdmin(server-side redirect to/loginif not signed in, to/with toast if not admin).UI sections (mirror the existing Memory tab's chrome — same
bg-muted/30shell, same shadcn primitives per rule #11):aggregate.tspattern from observability.keyLast4, baseUrl, priority, enabled toggle, last used, last success / last error (with hover for full error message), actions (edit / rotate / test / delete).OPENAI_API_KEYfrom env."The "Users" sub-tab (under
/admin/users) reusescomponents/settings/memory-view.tsx's table pattern — same skeleton, different data.XSS / security
apiKeyfield on the create form:<input type="password">+autocomplete="off"+ never echoed back via React state (one-shot, dispatched on submit).keyLast4in the list — never the full key.Acceptance criteria
user.rolecolumn with default'user'; existing users unchanged.INITIAL_ADMIN_EMAILenv var promotes matching users to admin on login./admin/keys.priority ASCorder with sequential fallback on retryable errors.llm_keytable → app falls back toOPENAI_API_KEYenv (today's behavior)./admin/*get 403 (or redirect to/with toast).keyLast4is the only field returned in any list / detail.last_used_at,last_success_at,last_error_at,last_error_messageupdated on every chat invocation.pnpm lint,pnpm typecheck,pnpm testgreen.apiKey/encryptedKeyever appears in any log, span, or response payload. The existingFORBIDDENregex inbulkInsertSpansalready redactsapi[_-]?key— verify with a span assertion test that the new key-handling path doesn't bypass it.docs/APIS.mdupdated (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1).docs/AUTH.mdupdated with the new env var + admin role mechanics.docs/DB.mdupdated with the new tables.TDD plan (rule #2)
tests/api/admin/keys.test.ts— non-admin user → 403 on every route. RED → GREEN viarequireAdmin.tests/lib/admin/encryption.test.ts— encrypt-then-decrypt round-trip; tampered ciphertext fails; wrong KEK fails. RED → GREEN.tests/backend/model/fallback.test.ts— mock the S3… sorry, the OpenAI client; force key A to 429 once, assert key B is tried next and the result is returned from B. Force key A to 401 (non-retryable) — assert no fallback, error propagates. RED → GREEN.tests/backend/model/fallback.test.ts— emptyllm_keytable,process.env.OPENAI_API_KEYset; assert the synthetic single-key fallback works. RED → GREEN.tests/api/admin/bootstrap.test.ts—INITIAL_ADMIN_EMAIL="alice@example.com", user with that email logs in,user.role === 'admin'after. Set the env to a different email — assert no promotion. RED → GREEN.tests/frontend/admin/keys.test.tsx— render the keys page with 2 mocked keys; assertkeyLast4displayed, no full key visible; click "test" → loading state → success / error state. RED → GREEN.tests/observability/redaction.test.ts— assert a chat run that goes through a key-pool path produces spans whosemeta.llm_kwargsdoes NOT containapiKey,OPENAI_API_KEY, or the decrypted plaintext. (Carry-over from the existing redaction contract — seebulkInsertSpansFORBIDDENregex.)Notes for the implementer
crypto.createCipheriv('aes-256-gcm', ...)is fine. No new dep.maxAttempts = keys.lengthand don't add a hard cap (the pool is small by design).apiKeyis constructor-only — passing it viabindlater won't work").admin_audit_logtable for forensics. Out of scope for MVP — flag in follow-up.Related
backend/model.ts— the twoChatOpenAIsingletons this issue replaces.lib/auth/with-auth.ts— thewithAuthwrapper thatrequireAdminbuilds on.lib/memory/merge.ts+backend/store.ts— existing module pattern to mirror forlib/admin/.lib/observability/callback-collector.ts— existing redaction that must continue to cover the new key-pool path (seeFORBIDDENregex inbulkInsertSpans).docs/AUTH.md— operator guide; gets the new env var + role mechanics.docs/DB.md— schema conventions; new tables follow the same shape.docs/APIS.md— new admin routes (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1)..env.example—INITIAL_ADMIN_EMAIL+LLM_KEY_ENCRYPTION_KEYgo here.