Repository navigation
llm-auth: one credential per provider per tenant — add a multi-account credential pool with headroom-based selection #15029
Description
Parent: #15021
Problem
AutoBot can hold exactly one credential per provider per tenant. There is no account dimension
anywhere in the platform, so "this account is nearly out of quota, use the other one" is not
expressible.
Audit evidence:
llm_shared/provider_auth.py:146—_vault_secret_name(provider_name, subject="global")returns
f"provider_auth:{provider_name}:{subject}".subjectis the org/tenant (org_vault_subject,
:66), not an account.api/provider_auth.py:588,620—GET /api/llm-auth/status/{provider_name}and
DELETE /api/llm-auth/{provider_name}. Singular; no list endpoint, no account id.autobot-frontend/src/components/settings/ProviderOAuthConnect.vue:125,136— connect/disconnect a
single provider. No account list UI exists.llm_shared/provider_registry.py— providers registered byname;set_fallback_chainis a list
of provider names. No account selection at any layer.grep -rniE "account_pool|credential_rotation|rotate_account|switch_account|multi[- _]account"over
autobot-backend,autobot_shared,autobot-frontend/src→ zero hits.
The storage layer does not block this: models/secret.py keys on (owner_id, name) with a
String(256) name, JSONB metadata, expires_at and is_active. A name of the form
provider_auth:{provider}:{subject}:{account_id} fits today — no migration to the secrets table is
required.
Scope
llm_shared/provider_auth.py—_vault_secret_namegains an optional account segment. The
existing two-segment name remains the default account, so every currently-stored credential
keeps working untouched.api/provider_auth.py— addGET /api/llm-auth/accounts/{provider_name}; make status and delete
account-aware while keeping the current singular routes working against the default account.llm_shared/provider_registry.py— account selection at dispatch: pick the account with the most
headroom (llm: harvest provider rate-limit response headers into a cross-worker quota headroom store (the missing quota monitor) #15026), skippingcoolingandneeds_reauth(llm: degradation store cannot distinguish a dead credential from a transient failure — dead tokens retry every TTL forever #15022).- Audit attribution is not optional. Every
LLMResponserecords which account served it, and the
cost path (services/llm_cost_tracker.py,llc/services/budget.py) attributes to that account.
Without this, cost attribution and the access graph both break silently. - Pool membership is expressed through the existing
VaultRef/ principal namespace
(autobot_shared/secrets_vault.py, umbrella Umbrella/PRD: unify AutoBot secrets — one RBAC-governed store, vaults + one key abstraction (replaces 11 stores, 5 keys, 2 UIs) #10088) — never a parallel credential registry.
Risks to hold the line on
An account pool is a shared resource that RBAC currently has no vocabulary for. Multi-tenancy makes
this sharper: one tenant must never consume another's account, and an account grantable to a team
must go through the existing grant model, not a new one.
Acceptance criteria
- Existing single-credential deployments work with zero config change and zero migration —
proven, not asserted. - Multiple accounts can be connected per provider and listed.
- Dispatch selects the healthiest account by observed headroom;
cooling/needs_reauth
accounts are skipped. - Every response and every cost event names the serving account.
- A tenant cannot read, select, or delete another tenant's account — covered by a test, not by
inspection. - Deleting a non-default account does not disturb the default; deleting the last account is
handled explicitly. - Pool membership resolves through
VaultRef/ principal resolution; no second credential store
is introduced. - Tests: backward compatibility, multi-account selection, tenant isolation, per-account
attribution, exhausted-pool behaviour.
Implementation Order
Wave: 3
Depends on: #15026, #15022, #15028
Unblocks: #15021 Task 6
Activity
mrveiss commentedon Sep 12, 2026
Not implementable yet: this issue's own body says "Depends on: #15026, #15022, #15028" — all three are still OPEN. Adding
blockedso the backlog picker (which reads formal blocked_by relationships, not free-text "Depends on:") skips this until the quota-monitor prerequisites land.feat(llm): add the cross-worker quota headroom store, persist real 42…
feat(llm): add the cross-worker quota headroom store, persist real 42…
feat(llm): add the cross-worker quota headroom store, persist real 42…
feat(llm): add the cross-worker quota headroom store, persist real 42…