LiteLLM gateway & shared credit wallet — programme plan
Living document: update the status columns, the decision log and the change log in the same commit as the work they describe. Status legend: ✅ done · 🟡 in progress · ⬜ planned · 🚫 dropped · ❓ blocked on a decision.
1. Goal
All CIRCLE applications reach LLMs and MCP servers through a single LiteLLM proxy, authenticated with auth-service access tokens. Users buy prepaid credits (bought with Stripe when it is configured, or recorded by a superadmin otherwise) that are shared by every application and consumed per LLM token and/or per MCP tool call.
2. Target architecture
Apps ──OAuth (resource=<litellm url>)──► auth-service (IdP + wallet + Stripe)
│ ▲ │ JWKS
│ Bearer JWT │ ▼
└──────────────► LiteLLM proxy ── hooks ───┘ (balance check, idempotent debits)
│
└──► LLM providers / MCP servers
- auth-service is the source of truth for money (ledger, Stripe, the USD→EUR rate and the margin; model and tool prices stay in LiteLLM, D36).
- LiteLLM is the metering and enforcement point: it validates the JWT,
computes the raw cost, and calls auth-service through a custom callback
(option B):
async_pre_call_hook(balance check, short cache) andasync_log_success_event(debit with an idempotency key generated for each debit, D32). - Credit unit is monetary (integer micro-units), never tokens, so tokens and MCP calls share one balance. Raw quantities are stored in ledger metadata.
- LiteLLM is registered in auth-service as a protected resource (RFC 8707
audience). Public (PKCE) and private (confidential) applications are both
supported; the caller is identified by
azp, the user bysub.
3. Programme overview
| Lot | Scope | Status |
|---|---|---|
| 0 | This plan | ✅ |
| 1 | LiteLLM stack: compose profile, config, setup script, JWT smoke test | 🟡 (local ✅, dev server pending) |
| 2 | Wallet data model and core service | ✅ |
| 3 | Wallet APIs: internal (debit/balance), user, admin | ✅ (3.6 blocked on D11) |
| 4 | Stripe top-ups (Checkout payment mode, refunds, disputes) and manual top-ups | ✅ (not yet run against a Stripe account) |
| 5 | LiteLLM callback (option B): pre-check, debit, MCP, failure policy | ✅ |
| 6 | Pricing: exchange rate and margin editable by superadmins; model and tool prices stay in LiteLLM | ✅ |
| 7 | Frontend: balance, history, top-up; admin ledger view; statistics and events | ✅ (to validate on the dev server) |
| 8 | Hardening: reconciliation, caps, observability, tests, docs sync | 🟡 (8.6 findings fixed, re-check after dev-server tests) |
A lot is done only when pnpm build:server, pnpm lint, pnpm format:check,
pnpm test and pnpm test:integration pass (plus the lot-specific checks
below).
4. Lot details
Lot 1 — LiteLLM stack (local first, then dev server)
| # | Task | Status |
|---|---|---|
| 1.1 | Spike on local stack: LiteLLM v1.104.2 against auth-service (see section 5) | ✅ |
| 1.2 | Compose profile llm: litellm service, image pinned via LITELLM_IMAGE (default v1.105.0, see D23), healthcheck, internal network access to auth-service | ✅ |
| 1.3 | Dedicated litellm database on the existing Postgres: created by litellm-setup when LITELLM_DB_ADMIN_URL is set (src/scripts/litellm-database.ts); no initdb.d script ships in the repository | ✅ |
| 1.4 | litellm/config.yaml (general_settings.custom_auth) and litellm/custom_auth.py (JWKS validation, refetch on unknown kid, M2M rejection); models are managed at runtime and stored in the LiteLLM database, not listed in the config | ✅ |
| 1.5 | Idempotent pnpm llm:setup: registers the litellm resource application, optionally grants client applications (--grant=), generates LITELLM_MASTER_KEY / LITELLM_SALT_KEY into .env (a stored value is never regenerated; a value supplied in the environment wins). It also provisions the litellm-wallet machine client and its secret (3.2) and, when LITELLM_STATE_FILE is set, litellm-ui and the admin interface sign-in (D38) | ✅ |
| 1.6 | .env.example entries (no secrets) | ✅ |
| 1.7 | Smoke test: token with resource=<litellm url> → GET /v1/models → completion; negative cases (wrong audience, tampered, malformed, missing, client_credentials token) | ✅ |
| 1.8 | Docs page (guides/2.integration/4.litellm-gateway.md), roadmap link | ✅ |
| 1.9 | Deploy and validate on the dev server (pnpm llm:setup, docker compose --profile llm up -d, pnpm llm:smoke), plus a browser call from a public application to check LiteLLM CORS (not verified) | ⬜ |
| 1.11 | Gateway config shipped in the auth-service image (COPY litellm) and copied into a volume by litellm-setup (which replaced the earlier litellm-config-init, D35); litellm mounts it on /app/gateway (a dedicated path: /app/litellm already holds files of the LiteLLM image). Production deployment documented in the gateway guide: profiles, healthcheck on auth-service, Traefik labels, internal JWKS URL, no published port. The repository's docker-compose.yml itself publishes port 4000 for local use and carries no Traefik labels (compose.prod.yml was removed from the repository, see the change log of 2026-10-11) | ✅ |
| 1.12 | Gateway hardening: custom_auth.py limits user tokens to the LLM and MCP routes (role customer), the smoke test checks that /key/generate, /user/new, /team/new and /spend/logs are refused; the gateway guide gives Traefik labels that keep everything but the client routes on an administration network (they belong to the operator's compose file; this repository ships none) | ✅ |
| 1.10 | Per-application allowedResources list (D12): schema in the 0000 baseline, admin create/update validation (Zod, must reference existing resources), replaceClientResource links the app's own URL plus the allowed resources instead of wiping them, admin UI field, tests | ✅ |
Exit criteria: a user token issued by auth-service is accepted by LiteLLM and a
wrong-audience, tampered or client_credentials token is rejected, on local
and dev. The spike script (/authorize PKCE flow → LiteLLM) becomes the
reusable smoke test (1.7).
Lot 2 — Wallet data model and core service
| # | Task | Status |
|---|---|---|
| 2.1 | Drizzle tables (owner_id has no FK: polymorphic; owner existence is verified by getOrCreateWalletAccount): wallet_accounts (owner_type user/org, owner_id, balance, is_unlimited; no negative balance except through usage and reversals, D8), wallet_transactions (append-only ledger: delta, type, idempotency_key unique, balance_after, metadata, application_id nullable) | ✅ |
| 2.2 | Inline in 0000_initial_schema.sql (D5): delete post-baseline migrations and snapshots, re-run pnpm db:generate, review SQL | ✅ |
| 2.3 | services/wallet/: atomic credit/debit, idempotent replay returns the original result, overdraft check (only usage and reversals may take the balance below zero, D8) | ✅ |
| 2.4 | Error codes WAL_NNN in src/errors.ts, specification registry and the AGENTS.md prefix list (D6) | ✅ |
| 2.5 | Unit tests for concurrency-sensitive logic; integration tests on real Postgres | ✅ |
Lot 3 — Wallet APIs
| # | Task | Status |
|---|---|---|
| 3.1 | Internal API (client_credentials, scope wallet:debit / wallet:read): GET /api/internal/wallet/users/:userId, POST …/usage; dedicated rate limit (6000/min per IP, the global 600/min would throttle the gateway) | ✅ |
| 3.2 | Scope enforcement for machine callers: PUT /api/admin/applications/:id/machine-scopes (superadmin, allowlist m2m / wallet:read / wallet:debit / billing:read), requireMachineScope also checks the client's current configuration; pnpm llm:setup provisions litellm-wallet and its secret | ✅ |
| 3.3 | User API (session, or a user-bound JWT for the auth-service audience with scope wallet:self:read): GET /api/user/wallet, /transactions (keyset pagination, type filter), /stats, /usage, /topup-options; POST /checkout needs the session. Auth-service registers itself as the system application auth-service (resource BETTER_AUTH_URL, scope wallet:self:read) in src/bootstrap.ts so applications can request that JWT | ✅ |
| 3.4 | Admin API (requireAdmin): grant, adjust, inspect a ledger, toggle is_unlimited (audited in wallet_account_events). Reads for admins, writes for superadmins only (D16) | ✅ |
| 3.5 | Route tests via app.inject(): unit tests per route and full-server integration tests with real client_credentials tokens | ✅ |
| 3.6 | Org wallet rules (who tops up, who spends, which wallet a token resolves to) — only after D11 | ❓ |
Lot 4 — Stripe top-ups
| # | Task | Status |
|---|---|---|
| 4.1 | POST /api/user/wallet/checkout (amountCents within WALLET_TOPUP_MIN_EUR / _MAX_EUR), GET …/topup-options; EUR, Adaptive Pricing on, credit taken from the session metadata | ✅ |
| 4.2 | Wallet events handled by services/wallet/stripe-events.ts, called from the checkout.session.completed (non-subscription) and default branches of the webhook (existing stripe_events idempotency kept): checkout.session.completed / async_payment_succeeded credit | ✅ |
| 4.3 | Refunds (cumulative, partial or full) and disputes take credit back through the reversal type, which may leave the wallet in debt (D8). Serialised per payment intent; a won dispute is not restored automatically (admin grant) | ✅ |
| 4.4 | Currency delegated to Stripe (Adaptive Pricing, D9). Each top-up Checkout also creates a Stripe invoice (invoice_creation enabled in checkout.ts). Still open: tax collection (Stripe Tax, not enabled in the Checkout session) | ❓ (partly) |
| 4.5 | Tests: event fixtures on real PostgreSQL (replay, partial and cumulative refunds, debt, concurrent events, disputes) and unit tests. A signed-webhook route test hands a wallet checkout to the wallet service (billing-webhook.integration.test.ts) | ✅ |
| 4.6 | Manual mode without STRIPE_SECRET_KEY: POST /api/admin/wallets/users/:userId/topups (superadmin) records credit against an invoice reference; checkout answers 503 WAL_006 | ✅ |
Lot 5 — LiteLLM callback (option B)
| # | Task | Status |
|---|---|---|
| 5.1 | litellm/circle_wallet.py custom logger, shipped in the auth-service image and loaded through litellm_settings.callbacks | ✅ |
| 5.2 | async_pre_call_hook: refuses with 402 when the account cannot spend (zero or negative balance); balance cached 5 s; fails closed with 503 when the wallet is unreachable, except for a balance read in the last 60 s | ✅ |
| 5.3 | async_log_success_event: reports the provider cost in USD; auth-service prices it. Idempotency key litellm:<id generated by the hook for each debit> (not the LiteLLM call id, D32; the call id is kept in the metadata as requestId), application slug (azp) and raw quantities in the ledger metadata | ✅ |
| 5.4 | Spool on a volume (/var/lib/circle-wallet/pending) written before the first attempt, replayed with backoff, also after a restart and without traffic; permanent refusals go to dead/. Verified: outage, LiteLLM restart during the outage, recovery, 0 lost | ✅ |
| 5.5 | MCP: the same hook bills tool calls (call_type = call_mcp_tool, cost from mcp_server_cost_info), once per call, with server and tool in the metadata. Applications reach only the servers allowed to their team (D41, D42). Verified by hand with a mock MCP server; not part of the smoke test (it needs one) | ✅ |
| 5.6 | Reject client_credentials tokens for billable calls (D4): implemented in custom_auth.py (1.4) | ✅ |
| 5.7 | End-to-end: pnpm llm:smoke covers top-up → call → billed → debt → 402 → top-up → call (the script reports each check as PASS or FAIL) | ✅ |
Lot 6 — Pricing
| # | Task | Status |
|---|---|---|
| 6.1 | Pricing in auth-service: provider cost in USD × exchange rate × (1 + margin) → micro-euros. One global margin; model and tool prices stay in LiteLLM (D36) | ✅ |
| 6.2 | Admin management of the rate and margin: append-only wallet_pricing_settings (author and reason kept), GET/PUT /api/admin/wallets/pricing (read by admins, written by superadmins), pricing card on the Wallets page; the environment values are only the starting point | ✅ |
| 6.3 | Pricing applied at debit time; raw quantities kept for re-pricing and audit | ✅ |
| 6.4 | USD→EUR conversion with an explicit configured rate, validated by config.ts (no default; 503 WAL_007 until set) | ✅ |
Lot 7 — Frontend
| # | Task | Status |
|---|---|---|
| 7.1 | User: "Credit" tab of the profile page: balance (available, in debt, unlimited), top-up (presets and custom amount, or "ask your administrator" when online payment is off), 30-day spend chart stacked by model with breakdown by application, type and model, daily consumption table (each day opens a per-model detail; the raw ledger is not shown to users, D43) | ✅ |
| 7.2 | Admin: "Wallets" page (totals, spend chart, top consumers, filterable list) and a credit section on the user page (balance, history, top-up, grant, signed adjustment, unlimited access; writes are superadmin-only) | ✅ |
| 7.3 | Statistics API (GET /api/user/wallet/stats, /api/admin/wallets, /stats, /users/:id/stats) and the wallet.changed event; wallet cards on the dashboard | ✅ |
Lot 8 — Hardening and documentation
| # | Task | Status |
|---|---|---|
| 8.1 | Reconciliation: LiteLLM spend logs vs ledger (pnpm llm:reconcile, GET /api/admin/wallets/usage-ledger); run by hand or from a scheduler, not yet automatic | ✅ |
| 8.2 | Per-user ceilings in the gateway hook: calls per minute, spend per minute and simultaneous model calls (a per-request cost cap is not possible before the call; see D33) | ✅ |
| 8.3 | Logs: every usage debit and administrator balance change, a warning when a wallet goes into debt, gateway warnings for a stuck spool and for dead/; no token or secret logged. Metrics: the statistics endpoints and dashboard; a Prometheus endpoint is not built | 🟡 |
| 8.4 | Coverage ≥ 80 % on the wallet code (unit and integration runs together; checkout.ts is covered by its unit test). pnpm test:coverage measures the unit suite alone (threshold lines: 80 in vitest.config.ts) and is well below 80 % for the whole repository, as before this work | 🟡 |
| 8.5 | Guide, API reference, specifications and .env.example updated; roadmap and changelog left for the release | 🟡 |
| 8.6 | Security review of the wallet, internal API, gateway hook and new pages: two findings, both addressed (D32, D33) | ✅ |
5. Decision log
| ID | Decision | Date |
|---|---|---|
| D1 | auth-service owns the wallet; LiteLLM meters and enforces | 2026-10-09 |
| D2 | LiteLLM callback (option B) from the start, not budget projection | 2026-10-09 |
| D3 | Monetary credit unit, tokens and MCP calls priced into the same balance | 2026-10-09 |
| D12 | Applications are granted access to protected resources (such as LiteLLM) through a new per-application allowedResources setting, not through an automatic or script-only link | 2026-10-09 |
| D13 | Ledger unit is the integer micro-euro (bigint, 1 EUR = 1 000 000): exact for sub-cent LLM costs, no floating point. Pricing (lot 6) rounds up to at least 1 unit per billable operation. API layers must serialise amounts as strings (JSON has no bigint) | 2026-10-09 |
| D14 | Ledger rows carry both amount (signed value) and delta (effect on the balance): usage on an unlimited account has delta = 0, so balance = SUM(delta) always holds while the real spend stays auditable | 2026-10-09 |
| D15 | Wallet operations are serialised per account with a row lock (SELECT … FOR UPDATE) in one transaction (overdraft check, balance update, ledger insert); the unique (account_id, idempotency_key) index and CHECK constraints are the safety net. Rejected operations leave no ledger row | 2026-10-09 |
| D16 | Wallet writes (grants, adjustments, unlimited toggle) and machine-scope changes are superadmin-only; admins can read. Money creation and machine privileges follow least privilege. Easy to relax per route if operations need it | 2026-10-09 |
| D17 | The wallet API is keyed by user id in the path (/users/:userId) so org wallets can be added as a sibling path later; a wallet is created empty on first read or write | 2026-10-09 |
| D18 | Decided 2026-10-11: no clamping and no rejection. Usage is always recorded; the debt shows as a negative balance that blocks the application (canSpend) and the gateway pre-check, so callers cannot loop on a small remaining balance | 2026-10-11 |
| D23 | LiteLLM v1.105.0 is the pinned default (needed for local vLLM servers). custom_auth.py lets LiteLLM validate its own virtual keys (sk-…): the admin UI signs in with one created at login, so without this the UI got 401 on every call (found 2026-10-11). Administrators may also issue service keys; users cannot mint any. Keys are not billed per user (no sub): the gateway hook (lot 5) must treat them as service traffic | 2026-10-11 |
| D24 | LiteLLM SSO for the admin UI is configured through GENERIC_* environment variables (free up to 5 users). Nothing was configured at the time, which is why no SSO appeared: the UI signed in with the master key. Using auth-service as the OIDC provider for the UI was optional and has since been done in D38 | 2026-10-11 |
| D20 | The LiteLLM gateway config travels inside the auth-service image and is copied to a shared volume at start-up, instead of being copied by hand next to the compose file. Same image tag for both, no CI change. A dedicated LiteLLM image built by a workflow stays possible later | 2026-10-11 |
| D21 | User tokens can only call the LLM and MCP routes of LiteLLM (RouteChecks.is_llm_api_route plus /{server}/mcp); administration requires the master key. Found because custom_auth bypasses LiteLLM's role checks: a valid user token could create virtual keys, users and teams | 2026-10-11 |
| D22 | The admin UI and management API are private: Traefik exposes only an allow list of client route prefixes publicly and restricts the rest to an administration CIDR (a Traefik setting of the operator's compose file: this repository's docker-compose.yml ships no Traefik labels) | 2026-10-11 |
| D26 | Pricing lives in auth-service: the gateway reports the raw provider cost in USD and never computes a price. The exchange rate has no default; the pricing facts (cost, rate, margin) are stored in each ledger row. A retry that crosses a rate change is refused as a conflicting replay and set aside for a human | 2026-10-11 |
| D27 | Failure policy of the gateway hook: billing is never lost (disk spool, idempotent retries); the credit check fails closed (503) except for a balance read within the last minute, so a short outage does not interrupt users who were just served | 2026-10-11 |
| D28 | circle-users access group | 2026-10-11 |
| D29 | Statistics are computed on demand from the ledger (dense UTC daily series, per application and per type). No aggregate table: revisit when a ledger is large enough for the queries to show in the logs | 2026-10-11 |
| D30 | wallet.changed joins the admin event channel as a signal without payload, throttled to one frame per second per instance (leading edge, then one trailing frame). Users have no event channel (SSE is admin-only): the profile page polls every 15 s while visible, and every 3 s for 30 s after a payment return | 2026-10-11 |
| D31 | The Stripe return URLs are the profile page (/profile?topup=success|cancelled), which opens the Credit tab. The frontend only offers amounts in whole cents inside the bounds of topup-options | 2026-10-11 |
| D32 | Security finding (high, fixed). The ledger idempotency key was LiteLLM's call id, which a client controls through the x-litellm-call-id header: reusing one id made every later call a replay, so it was never charged. The key is now generated by the hook for each debit; the call id is kept in the ledger metadata (requestId) for reconciliation only. A smoke check sends three calls with the same id and expects all three to be billed | 2026-10-11 |
| D33 | Security finding (medium, mitigated). Credit is checked before a call and billed after it, and usage may overdraw, so concurrent expensive calls on a small balance could create a large debt. Mitigation: per-user ceilings on calls per minute (120), spend per minute (5 USD, from the cost LiteLLM reports) and simultaneous model calls (8). Not done: reserving an estimated cost, or a per-account debt ceiling, which would have to refuse or truncate a call already made. Residual risk: a debt of a few calls' cost per account, owed by someone who may abandon the account | 2026-10-11 |
| D34 | Reconciliation compares only calls authenticated with a user token and leaves out the last 10 minutes (retry spool, delayed spend-log batches). It is an operator tool run on demand; scheduling it and alerting on its result belongs to the deployment | 2026-10-11 |
| D35 | Zero-touch provisioning: the compose service litellm-setup (auth-service image, one-shot) replaces the manual pnpm llm:setup and the config-copy service. It generates the master and salt keys once (operator-supplied values win), registers the litellm application and the litellm-wallet client, and stores the secrets in secrets.env on the volume shared with the gateway, which reads it in its entrypoint. Requires an administrator without MFA at start. A 429 from the token endpoint no longer counts as a rejected wallet secret (it rotated a valid one) | 2026-10-11 |
| D36 | Model and tool prices are not duplicated in auth-service: LiteLLM already prices tokens (cache, reasoning, tiers, images, audio, per second) and MCP calls, and its spend logs must keep matching the ledger. auth-service owns only what LiteLLM cannot: the euro wallet, Stripe, the USD→EUR rate and one margin, stored in the database and edited by superadmins. A per-model margin can be added later if needed | 2026-10-11 |
| D37 | trusted_proxy_ranges is set in the shipped LiteLLM config to the private address ranges, so the Admin UI sign-in throttle works per client behind the reverse proxy. Verified: failed sign-ins from one X-Forwarded-For address are blocked while another address still signs in. custom_auth_run_common_checks was left unset here; D41 turns it on for per-team model access | 2026-10-11 |
| D38 | Admin UI sign-in through auth-service (follows up D24): litellm-setup registers a private confidential application litellm-ui (redirect /sso/callback, scopes openid profile email roles, PKCE) and stores GENERIC_CLIENT_ID / GENERIC_CLIENT_SECRET in secrets.env; the endpoints and mappings (ssoGatewayEnv in src/scripts/litellm-lib.ts) are written there too, so the compose file carries none of them. Access is managed in auth-service: only users granted the application can sign in, its admin role maps to LiteLLM proxy_admin and its user role (and any other grant) to the read-only proxy_admin_viewer. The setup administrator, who holds admin as the application's creator, is the first LiteLLM admin. The login page redirects straight to SSO (AUTO_REDIRECT_UI_LOGIN_TO_SSO); the master key initially still signed in at /fallback/login, a login closed afterwards (see below and the change log of 2026-10-12). LITELLM_SSO_ENABLED=false removes the stored client id, client secret and sign-in settings and turns it off. Verified end to end in a browser: sign-in through auth-service, role proxy_admin in LiteLLM, configuration visible under Settings → Admin Settings → SSO Settings. Username/password sign-in is closed while SSO is configured (disable_password_login_when_sso_enabled: master-key login included, so an auth-service outage does not open the interface; the master key still works on the API; turning SSO off brings the login back). Verified: /login answers 403 with SSO on and signs in with SSO off | 2026-10-11 |
| D39 | Applications get an isSystem flag: hidden by default in the admin list (toggle "Show system applications", remembered in the browser), refused deletion (APP_007), settable only by a superadmin. The API still returns them, so scripts keep finding them. litellm-setup marks litellm, litellm-wallet and litellm-ui as system, including existing ones. Deliberately not done: auto-assigning every user to system applications (it would give every user viewer access to the LiteLLM interface through litellm-ui; the LiteLLM resource application needs no user grant) | 2026-10-12 |
| D40 | SCIM is not built: LiteLLM's SCIM is an Enterprise feature, and the useful direction for auth-service would be a SCIM server (organizations as groups) for customers' identity providers, to be done when asked. Roles, permissions, features and application attributes already reach applications as OIDC claims at sign-in | 2026-10-12 |
| D41 | (Amended by D42: the unit is now the plan, not the application.) One gateway team per application with LiteLLM support (team id = slug), created closed (models: ["no-default-models"]); models, MCP servers, budget and limits are set only in the LiteLLM admin interface and never overwritten by auth-service. Support = the gateway URL in allowedResources. Created before the save (failure: SRV_004, nothing saved); disabled → team blocked (marker blocked_by_auth_service, so an administrator's own block is never lifted); application deleted → team deleted; only teams marked managed_by: auth-service are blocked by the reconciler (startup + every 5 min). custom_auth.py reads the team and copies its block, limits, models, budget and MCP permission onto the identity, because LiteLLM does not apply them to a custom auth by itself (custom_auth_run_common_checks: true adds the model list and budget); an application without a team gets 403. Verified on LiteLLM v1.105.0: closed team 403, blocked 401, rpm_limit 429, MCP limited to the team's servers; admin changes apply within ~1 min (team cache). Gateway key: a virtual key owned by a proxy_admin user and restricted by allowed_routes to the team routes, issued by the one-shot litellm-provision; it cannot add models, mint keys or serve calls. Rejected: one virtual key per application (a key is not a policy unit, and a stored secret to rotate) | 2026-10-13 |
| D42 | One gateway team per (application × plan), amending D41 (which had one per application): team id <applicationSlug>:<planSlug>, alias <app name> - <plan name>, so a user's plan decides what they reach. Each team carries a unified Access Group of the same name, created empty by auth-service and filled by an administrator (models + MCP servers + agents in one object). custom_auth.py no longer derives the team from azp alone: it resolves the user's plan for the calling application from auth-service (GET /api/internal/gateway/users/:userId/plan, machine scope wallet:read (reused, so an in-place upgrade needs no new scope grant), ~30 s cache, 503 fail-closed) so an upgrade or a downgrade applies at once; a user with no active subscription falls back to the application's default plan. Plan lifecycle: team created before the save (SRV_004 on failure), a rename creates the new team and retires the old one, a delete blocks the team and drops its group, removing the support blocks all of an application's teams and deleting it deletes them. The provisioner key's routes gain /v1/access_group and /v1/access_group/*. Provider secrets are LLM Credentials (named, reusable) referenced by litellm_params.litellm_credential_name, never by an env var per provider; models and access groups are exported/imported as JSON (pnpm llm:export / llm:import, upsert by name, never touching teams). Verified on LiteLLM v1.106.2: a team's Access Group grants a model (200) where a closed team denies it (403), and emptying the group revokes it; the MCP path resolves team.access_group_ids the same way | 2026-10-27 |
| D43 | Consumption is shown aggregated, by day (grouped by application) and by model, not as a raw ledger. The ledger metadata already carries kind, model and the token counts; the gateway now also forwards the client's chat session_id (from LiteLLM metadata.session_id, bounded to 200 chars, never used for authorization). The wallet stats gain byModel and a per-day daily split; GET /api/user/wallet/usage?sessionId= returns what one session cost, scoped to the calling application (bearer azp, the immutable OAuth client id) so two applications cannot read each other's identical session ids. Charts use a distinct colour palette per dimension (application, type, model), the daily chart is a stacked bar by model, and each day row opens a detail with per-application and per-model figures (tokens and amount). The raw transaction ledger leaves the user pages; the credit events stay an administrator, on-demand view. Per-session spend is not visualized in auth-service — an application represents it on its side | 2026-10-27 |
| D10 | LiteLLM native JWT auth is Enterprise-only (403 JWT Auth is an enterprise only feature on v1.104.2). We use the open custom_auth hook with our own JWKS validation instead of buying a licence; revisit if LiteLLM Enterprise is adopted for other reasons | 2026-10-09 |
| D4 | client_credentials tokens (no sub) are rejected for billable calls | 2026-10-09 |
| D5 | Wallet tables are inlined in the 0000 baseline (no production deploy yet; pre-production exception of AGENTS.md applies: regenerate snapshot, recreate databases) | 2026-10-09 |
| D6 | Dedicated WAL_ error family: wallet errors (insufficient balance, idempotency conflict, unlimited account, wrong owner) do not overlap the SUB_ plan/subscription semantics. AGENTS.md prefix list is updated in lot 2 | 2026-10-09 |
| D7 | Account owner is generic (owner_type user or org, owner_id); MVP implements the user wallet only. Org wallets need explicit rules for who tops up and who spends (lot 3.6) | 2026-10-09 |
| D8 | Credits never expire. Unlimited access is an explicit is_unlimited flag, not a magic balance value. Amended 2026-10-11: the balance may go negative, but only through operations that record something already done: usage reported after the call, and payment reversals (refund, dispute). A manual debit (adjust) may never take it below zero. A negative balance blocks spending (canSpend: false) and is repaid by the next top-up | 2026-10-09 |
| D9 | Ledger currency is EUR. Top-up packs are priced in EUR; local-currency display is delegated to Stripe (adaptive pricing) when simple. LiteLLM costs are in USD, so pricing applies a configured USD→EUR rate (lot 6.4) | 2026-10-09 |
6. Open questions
| ID | Question | Blocks |
|---|---|---|
| D19 | Deleting a user leaves their wallet and ledger behind (no FK, ledger is append-only and kept for accounting). Retention, anonymisation and refund of a remaining balance need a policy | Lot 8 |
| D11 | Org wallets: who may top up and who may spend (roles)? Which wallet is charged when a token carries org_id? | Lot 3.6 |
6b. Spike findings (lot 1.1, local, 2026-10-09)
Setup: throwaway Postgres, auth-service on the host, ghcr.io/berriai/litellm:v1.104.2
(latest and main-stable both resolve to it today), mocked model.
- Native JWT auth is Enterprise-only → D10.
custom_authworks without a licence. - Access tokens: a JWT is issued only when
resource=<litellm url>is sent, otherwise the token is opaque. Algorithm EdDSA (Ed25519),kidset,iss=BETTER_AUTH_URL(no path),aud=[<litellm url>, <userinfo url>],sub= user id,azp= application slug,sidpresent on user tokens. client_credentialstokens:audis a single string andsub == azp == client_id(no user, nosid) → rejected by comparingsubandazp.- Resource access: a client must be linked to the resource, otherwise
/authorizereturnsinvalid_target(D12).replaceClientResourcedeletes every link of a client on create/update and only re-links the client's own URL, so a manual link is wiped by the next application update. - Validated with
custom_auth.py: public (PKCE) and private application user tokens → 200; master key → 200; M2M, wrong audience, tampered signature, garbage and missing token → 401. Spend logs recorduser= AuthServicesub. - Observations: rejected requests are also written to the spend logs as
failurerows (noise for reconciliation, filter on status); the image shipspyjwtandcryptography(EdDSA supported);custom_auth_run_common_checksmust be set explicitly if DB budgets/model allowlists should apply. - Role checks bypassed (found on 2026-10-11): with
custom_authand no role, LiteLLM skipped its route authorisation: a plain user token got200on/key/generate,/user/newand/team/new(only/model/newand/config/updatewere refused by their own handlers). Fixed by D21. - Not yet verified: key rotation behaviour of the refetch logic, MCP hook coverage (5.5), behaviour behind Traefik/TLS on the dev server.
- Full stack check (compose, production-mode image):
pnpm llm:setupthenpnpm llm:smokepass 11/11 on a throwaway project./authorizeanswers with a JSON{ redirect, url }body instead of a 302 in production mode (the smoke client handles both).docker composeinterpolates services of inactive profiles, so required LiteLLM variables are enforced bycustom_auth.pyat start-up instead of${VAR:?}.
6c. Side fixes found while building lot 3
Both bugs pre-dated this programme and broke the contract of the new API, so they were fixed in the same change (each covered by tests):
- Error contract. The global error handler was registered after the route
plugins, so it never applied to them: a thrown
ApiErrorcame back as Fastify's default{ statusCode, code, error, message }(nodetails) and aZodErroras a500. The handler now lives insrc/error-handler.ts, is registered before the routes and is shared with the unit-test app. Framework 4xx errors (rate limit, malformed JSON) keep their default serialisation. One existing test had encoded the old shape and was updated. - Opaque access tokens. BetterAuth stores access tokens as SHA-256 hashes
but
verifyBearerAccessTokenlooked them up in clear, so every opaque token (aclient_credentialsrequest withoutresource) was refused, including on/api/consumption. The lookup now hashes the token and refuses revoked rows.
7. Risks
- auth-service on the critical path of every LLM call: cached balance,
explicit failure policy, wallet code isolated in
services/wallet/so it can be extracted. custom_authis our code on the hot path: it relies on theUserAPIKeyAuthcontract of LiteLLM, which can change between versions (pinned image + smoke test). JWKS keys are cached and refetched at most once per cooldown when an unknownkidappears, so a rotation is picked up fast.- LiteLLM upgrades can change hook behaviour: pin the image, re-run the smoke test and the end-to-end test on every bump.
- Overspend window between the pre-check and the post-call debit: usage is always recorded and may take the balance below zero (D8, D18), so the overspend becomes a debt owed by the user; it is bounded by the per-user ceilings (8.2, D33). A per-request cost cap is not possible before the call.
- Double charge or lost debit: idempotency keys, outbox and reconciliation.
8. Change log
| Date | Change |
|---|---|
| 2026-10-09 | Plan created |
| 2026-10-09 | Decisions D4–D9 recorded; D11 (org wallet rules) opened; lot 1 started |
| 2026-10-09 | Lot 1.1 spike done: D10 resolved (custom_auth), D12 opened and decided (per-application allowedResources), image pinned to v1.104.2 |
| 2026-10-09 | Lot 1.10 done: applications.allowed_resources (inlined in 0000), validation, link sync, admin UI field, integration tests, docs |
| 2026-10-09 | Lot 1 delivered locally: compose profile llm, litellm/ config and custom_auth.py, pnpm llm:setup, pnpm llm:smoke, guide page; 1.9 (dev server) pending |
| 2026-10-09 | Lot 2 delivered: wallet_accounts / wallet_transactions inlined in 0000, services/wallet/ (credit, debit, accounts), WAL_001–WAL_004, unit + integration tests; D13–D15 recorded |
| 2026-10-09 | Lot 3 delivered: internal / user / admin wallet APIs, machine scopes, wallet_account_events audit table, litellm-wallet provisioning in pnpm llm:setup, smoke test extended; side fixes (error handler order, opaque token lookup); D16–D17 recorded, D18–D19 opened |
| 2026-10-11 | First deployment on the test server: COMPOSE_PROFILES, missing auth-service healthcheck, Traefik labels and manual config copy fixed (1.11, D20) |
| 2026-10-11 | Gateway hardening: route allow list in custom_auth.py (D21), private admin interface through Traefik (D22), smoke test extended to 21 checks |
| 2026-10-11 | Decisions: negative balance allowed for usage and reversals (D8 amended, D18 decided); currency delegated to Stripe. Lot 4 delivered (checkout, events, manual top-up, reversal type, canSpend). LiteLLM bumped to v1.105.0, admin UI compatibility fixed (D23), SSO question answered (D24) |
| 2026-10-11 | Lot 5 delivered: gateway hook (credit check, billing, MCP, spool), dollar-cost pricing in auth-service (6.1 partial, 6.4), D26–D28. Smoke test at 28 checks on the full stack |
| 2026-10-11 | Lot 7 delivered: statistics service and endpoints, wallet.changed event, profile Credit tab, admin Wallets page and user credit section, dashboard wallet cards, D29–D31. 430 unit and 256 integration tests; frontend checked in mock mode, to validate on the dev server |
| 2026-10-11 | Lot 8 delivered except the optional parts (D32–D34): reconciliation script and endpoint, per-user gateway ceilings, logs, security review with its high finding fixed, docs. Smoke test at 30 checks |
| 2026-10-11 | First dev-server deployment of lot 8 failed on the missing wallet client secret (manual setup step). litellm-setup service added to docker-compose.yml: provisioning is now part of docker compose up (D35); compose.prod.yml removed from the repository |
| 2026-10-11 | Lot 6 closed: exchange rate and margin editable in the admin interface (D36); wallet_pricing_settings inlined in the 0000 baseline (pre-production exception: recreate the auth database) |
| 2026-10-11 | LiteLLM trusted_proxy_ranges configured (D37); stale admin UI session after a database reset diagnosed (not a deployment fault) |
| 2026-10-11 | Admin UI sign-in through auth-service delivered (D38): litellm-ui application, role mapping, PKCE, auto-redirect; litellm-setup provisions it |
| 2026-10-12 | Master-key login of the LiteLLM interface closed while SSO is on; isSystem applications (D39); SCIM assessed and deferred (D40) |
| 2026-10-27 | Migration to one gateway team per (application × plan) with a per-plan Access Group (D42): plan-aware team provisioning and reconciliation, plan resolution endpoint on the wallet:read scope in custom_auth.py, JSON models/access-groups export and import referencing LLM Credentials by name (export redacts every known provider-secret field and the output is gitignored). Verified the Access Group mechanism against LiteLLM v1.106.2 |
| 2026-10-27 | Wallet consumption aggregated by day (by application) and by model (D43): byModel and daily in the stats, GET /api/user/wallet/usage per chat session, sessionId forwarded by the gateway, per-dimension chart palettes and a per-day model detail; the raw ledger is now an administrator, on-demand view |
Functional specifications
Functional specifications — stack, database schema, authentication flows, OAuth 2.1 / OIDC, organizations, billing, error codes.
Stripe billing — programme plan
Living plan for per-product subscriptions (fixed + metered features) billed by Stripe, alongside the prepaid credit wallet, in auth-service.