Project

LiteLLM gateway & shared credit wallet — programme plan

Living plan for deploying LiteLLM behind auth-service OAuth and adding a cross-application prepaid credit wallet.

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) and async_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 by sub.

3. Programme overview

LotScopeStatus
0This plan✅
1LiteLLM stack: compose profile, config, setup script, JWT smoke test🟡 (local ✅, dev server pending)
2Wallet data model and core service✅
3Wallet APIs: internal (debit/balance), user, admin✅ (3.6 blocked on D11)
4Stripe top-ups (Checkout payment mode, refunds, disputes) and manual top-ups✅ (not yet run against a Stripe account)
5LiteLLM callback (option B): pre-check, debit, MCP, failure policy✅
6Pricing: exchange rate and margin editable by superadmins; model and tool prices stay in LiteLLM✅
7Frontend: balance, history, top-up; admin ledger view; statistics and events✅ (to validate on the dev server)
8Hardening: 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)

#TaskStatus
1.1Spike on local stack: LiteLLM v1.104.2 against auth-service (see section 5)✅
1.2Compose profile llm: litellm service, image pinned via LITELLM_IMAGE (default v1.105.0, see D23), healthcheck, internal network access to auth-service✅
1.3Dedicated 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.4litellm/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.5Idempotent 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.7Smoke test: token with resource=<litellm url> → GET /v1/models → completion; negative cases (wrong audience, tampered, malformed, missing, client_credentials token)✅
1.8Docs page (guides/2.integration/4.litellm-gateway.md), roadmap link✅
1.9Deploy 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.11Gateway 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.12Gateway 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.10Per-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

#TaskStatus
2.1Drizzle 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.2Inline in 0000_initial_schema.sql (D5): delete post-baseline migrations and snapshots, re-run pnpm db:generate, review SQL✅
2.3services/wallet/: atomic credit/debit, idempotent replay returns the original result, overdraft check (only usage and reversals may take the balance below zero, D8)✅
2.4Error codes WAL_NNN in src/errors.ts, specification registry and the AGENTS.md prefix list (D6)✅
2.5Unit tests for concurrency-sensitive logic; integration tests on real Postgres✅

Lot 3 — Wallet APIs

#TaskStatus
3.1Internal 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.2Scope 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.3User 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.4Admin API (requireAdmin): grant, adjust, inspect a ledger, toggle is_unlimited (audited in wallet_account_events). Reads for admins, writes for superadmins only (D16)✅
3.5Route tests via app.inject(): unit tests per route and full-server integration tests with real client_credentials tokens✅
3.6Org wallet rules (who tops up, who spends, which wallet a token resolves to) — only after D11❓

Lot 4 — Stripe top-ups

#TaskStatus
4.1POST /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.2Wallet 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.3Refunds (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.4Currency 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.5Tests: 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.6Manual 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)

#TaskStatus
5.1litellm/circle_wallet.py custom logger, shipped in the auth-service image and loaded through litellm_settings.callbacks✅
5.2async_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.3async_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.4Spool 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.5MCP: 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.6Reject client_credentials tokens for billable calls (D4): implemented in custom_auth.py (1.4)✅
5.7End-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

#TaskStatus
6.1Pricing 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.2Admin 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.3Pricing applied at debit time; raw quantities kept for re-pricing and audit✅
6.4USD→EUR conversion with an explicit configured rate, validated by config.ts (no default; 503 WAL_007 until set)✅

Lot 7 — Frontend

#TaskStatus
7.1User: "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.2Admin: "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.3Statistics 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

#TaskStatus
8.1Reconciliation: 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.2Per-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.3Logs: 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.4Coverage ≥ 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.5Guide, API reference, specifications and .env.example updated; roadmap and changelog left for the release🟡
8.6Security review of the wallet, internal API, gateway hook and new pages: two findings, both addressed (D32, D33)✅

5. Decision log

IDDecisionDate
D1auth-service owns the wallet; LiteLLM meters and enforces2026-10-09
D2LiteLLM callback (option B) from the start, not budget projection2026-10-09
D3Monetary credit unit, tokens and MCP calls priced into the same balance2026-10-09
D12Applications are granted access to protected resources (such as LiteLLM) through a new per-application allowedResources setting, not through an automatic or script-only link2026-10-09
D13Ledger 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
D14Ledger 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 auditable2026-10-09
D15Wallet 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 row2026-10-09
D16Wallet 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 it2026-10-09
D17The 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 write2026-10-09
D18Decided 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 balance2026-10-11
D23LiteLLM 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 traffic2026-10-11
D24LiteLLM 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 D382026-10-11
D20The 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 later2026-10-11
D21User 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 teams2026-10-11
D22The 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
D26Pricing 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 human2026-10-11
D27Failure 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 served2026-10-11
D28MCP access through the circle-users access group superseded by D412026-10-11
D29Statistics 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 logs2026-10-11
D30wallet.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 return2026-10-11
D31The 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-options2026-10-11
D32Security 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 billed2026-10-11
D33Security 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 account2026-10-11
D34Reconciliation 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 deployment2026-10-11
D35Zero-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
D36Model 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 needed2026-10-11
D37trusted_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 access2026-10-11
D38Admin 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 off2026-10-11
D39Applications 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
D40SCIM 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-in2026-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
D42One 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 way2026-10-27
D43Consumption 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 side2026-10-27
D10LiteLLM 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 reasons2026-10-09
D4client_credentials tokens (no sub) are rejected for billable calls2026-10-09
D5Wallet 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
D6Dedicated 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 22026-10-09
D7Account 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
D8Credits 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-up2026-10-09
D9Ledger 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

IDQuestionBlocks
D19Deleting 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 policyLot 8
D11Org 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_auth works without a licence.
  • Access tokens: a JWT is issued only when resource=<litellm url> is sent, otherwise the token is opaque. Algorithm EdDSA (Ed25519), kid set, iss = BETTER_AUTH_URL (no path), aud = [<litellm url>, <userinfo url>], sub = user id, azp = application slug, sid present on user tokens.
  • client_credentials tokens: aud is a single string and sub == azp == client_id (no user, no sid) → rejected by comparing sub and azp.
  • Resource access: a client must be linked to the resource, otherwise /authorize returns invalid_target (D12). replaceClientResource deletes 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 record user = AuthService sub.
  • Observations: rejected requests are also written to the spend logs as failure rows (noise for reconciliation, filter on status); the image ships pyjwt and cryptography (EdDSA supported); custom_auth_run_common_checks must be set explicitly if DB budgets/model allowlists should apply.
  • Role checks bypassed (found on 2026-10-11): with custom_auth and no role, LiteLLM skipped its route authorisation: a plain user token got 200 on /key/generate, /user/new and /team/new (only /model/new and /config/update were 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:setup then pnpm llm:smoke pass 11/11 on a throwaway project. /authorize answers with a JSON { redirect, url } body instead of a 302 in production mode (the smoke client handles both). docker compose interpolates services of inactive profiles, so required LiteLLM variables are enforced by custom_auth.py at 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 ApiError came back as Fastify's default { statusCode, code, error, message } (no details) and a ZodError as a 500. The handler now lives in src/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 verifyBearerAccessToken looked them up in clear, so every opaque token (a client_credentials request without resource) 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_auth is our code on the hot path: it relies on the UserAPIKeyAuth contract of LiteLLM, which can change between versions (pinned image + smoke test). JWKS keys are cached and refetched at most once per cooldown when an unknown kid appears, 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

DateChange
2026-10-09Plan created
2026-10-09Decisions D4–D9 recorded; D11 (org wallet rules) opened; lot 1 started
2026-10-09Lot 1.1 spike done: D10 resolved (custom_auth), D12 opened and decided (per-application allowedResources), image pinned to v1.104.2
2026-10-09Lot 1.10 done: applications.allowed_resources (inlined in 0000), validation, link sync, admin UI field, integration tests, docs
2026-10-09Lot 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-09Lot 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-09Lot 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-11First deployment on the test server: COMPOSE_PROFILES, missing auth-service healthcheck, Traefik labels and manual config copy fixed (1.11, D20)
2026-10-11Gateway hardening: route allow list in custom_auth.py (D21), private admin interface through Traefik (D22), smoke test extended to 21 checks
2026-10-11Decisions: 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-11Lot 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-11Lot 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-11Lot 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-11First 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 |

Copyright © 2026