A self-service web portal that lets users generate, manage, and renew their own LiteLLM API keys after authenticating with an OIDC provider. Administrators can define usage profiles (model access, rate limits, budgets) and assign them to individual users.
- Users log in via OIDC (e.g. Keycloak, Dex, Authentik).
- After login, each user lands on a dashboard where they can generate a personal LiteLLM API key.
- The key is created directly in LiteLLM with the parameters of the user's assigned profile.
- The key expires after a configurable number of days; users can extend or regenerate it at any time.
- Admins can manage profiles and assign users to them via
/admin.
- Self-service key management — generate, extend, regenerate, and delete LiteLLM API keys
- Profile system — per-user key validity, fair-use token quotas, model restrictions and TPM/RPM limits
- Usage reporting — users see what their key has consumed, per day and against their quota
- Model discovery — the dashboard lists the models a key may use, click to copy the exact name
- Expiry notifications — users are warned before their key expires, in the dashboard and (when SMTP is configured) by email, in German and English
- OIDC authentication — login, logout, and back-channel logout support
- SQLite storage — single file, no separate database server
- Admin panel — manage profiles and assign them to users
- Local development — Keycloak or a faster OIDC mock, both in
dev/
Copy .env.example to .env and fill in the values:
| Variable | Required | Default | Description |
|---|---|---|---|
LITELLM_BASE_URL |
yes | — | Base URL of the LiteLLM proxy |
LITELLM_MASTER_KEY |
yes | — | LiteLLM master key for key management |
OIDC_ISSUER_URL |
yes | — | OIDC provider issuer URL |
OIDC_CLIENT_ID |
yes | — | OIDC client ID |
OIDC_CLIENT_SECRET |
yes | — | OIDC client secret |
OIDC_REDIRECT_URL |
yes | — | Callback URL (must match OIDC client config) |
FRONTEND_URL |
yes | — | Public base URL of this app (shown to users as the API base URL) |
ADMIN_ROLE |
no | — | IdP role that grants the admin panel; supersedes ADMIN_IDS |
ADMIN_IDS |
no | — | Comma-separated admins, each an OIDC subject or an email |
ADMIN_EMAILS |
no | — | Deprecated alias for ADMIN_IDS; still read, email entries only |
DB_PATH |
no | ./data.db |
Path to the SQLite database file |
LISTEN_ADDR |
no | :8080 |
Address and port to listen on |
COOKIE_SECURE |
no | false |
Set true when serving over HTTPS |
SESSION_DURATION |
no | 24h |
How long a login session lasts |
KEY_DURATION_DAYS |
no | 90 |
Default key validity; profiles may override it |
SMTP_HOST |
no | — | host:port of a mail relay; unset disables expiry emails |
SMTP_FROM |
no | noreply@uni-osnabrueck.de |
Sender address for expiry emails |
SMTP_USERNAME |
no | — | Only if the relay requires authentication |
SMTP_PASSWORD |
no | — | Only if the relay requires authentication |
LOG_LEVEL |
no | info |
debug, info, warn or error |
go run ./cmd/serverThe server runs database migrations and seeds a default profile on startup.
The app needs an OIDC provider before it will serve traffic — it fetches the
discovery document at startup. dev/ provides two; see dev/README.md for
which to use when.
docker compose -f dev/docker-compose.yml up -d # Keycloak, ~20s
docker compose -f dev/docker-compose.yml --profile mock up -d # OIDC mock, ~8sKeycloak is the software production runs, so it is what to use when touching anything auth-shaped. The mock starts faster and needs no realm import, but serves no back-channel logout, so that path cannot be exercised against it.
The auth-path tests need neither: internal/oidc/mockprovider_test.go runs an
in-process issuer, so go test ./... requires nothing external.
Prefer ADMIN_ROLE. Set it to a realm role and admin membership is managed
in the IdP, where staff changes are already handled — the portal stops being a
second list to keep in step, and nothing has to be redeployed when someone
joins or leaves. It needs the IdP team to create the role and add a mapper that
puts it in the ID token: Keycloak sends realm roles only in the access
token by default. dev/realm-export.json contains a working example of both.
A realm that emits no role claim falls back to ADMIN_IDS, so configuring a
role before the IdP is ready locks nobody out. With ADMIN_ROLE unset, roles
are ignored entirely.
Users listed in ADMIN_IDS see an Admin link in the header. An entry is
either an OIDC subject or an email address, and the subject is the form to
prefer: an address is assigned by the IdP and can be reassigned, so an
allowlist keyed on it grants admin to whoever holds that address today rather
than to a person. Each user's subject is shown in the admin panel's user table,
click to copy. Granting by email still works and is logged as such, so an
existing ADMIN_EMAILS deployment keeps running while it is migrated. The admin panel at /admin provides:
- Profiles — create and edit profiles with model restrictions, TPM/RPM limits, and budget caps. Mark one profile as default; it applies to users with no explicit profile assignment.
- Users — view everyone who has logged in, see their key prefix and expiry, assign a profile, and revoke a key.
- Audit log — the 50 most recent key and profile changes, recording who did what to whom. Rows outlive the key and user they describe, so revoking does not erase the history.
Profile fields:
| Field | Description |
|---|---|
| Models | Comma-separated list of allowed model names (empty = all models) |
| Key validity | How long a generated key lasts, in days (blank = KEY_DURATION_DAYS) |
| Usage limit | Fair-use allowance in tokens per period (blank = unlimited) |
| Limit resets | hourly, daily, weekly or monthly |
| TPM limit | Maximum tokens per minute — burst control, complements the usage limit |
| RPM limit | Maximum requests per minute |
Different cohorts get different profiles: students might get 30-day keys with a 1M-token daily allowance, lecturers 365-day keys with no quota.
Extend sets the expiry to now + the profile's key validity. It does not add to the existing expiry, so clicking twice does not stockpile time, and it does not use the duration the key was originally created with — that is never stored.
The consequence is that policy changes apply on the next extend: move a user from a 30-day profile to a 365-day one and their existing key extends by 365, without needing to be regenerated.
Admins configure quotas in tokens; LiteLLM enforces spend. The portal
converts using a nominal per-token price (internal/litellm/quota.go), so a
1,000,000-token daily allowance becomes a $0.10 cap that resets every 24h.
Requests fail with HTTP 429 once the allowance is spent and resume when the
period resets.
This requires every model in LiteLLM to carry that same nominal price. A model
priced at 0 or null accrues no spend, so a quota over it never triggers.
One period per profile — a portal limit, not a gateway one. A profile exposes a single allowance and period, so it cannot combine caps the way Anthropic's plans do (e.g. 100k/day and 1M/month).
LiteLLM itself can: a key accepts budget_limits as a list of
{budget_duration, max_budget} objects and enforces each window
independently, rejecting with ExceededBudget: Key over 1h budget. This was
not true of v1.90.0, where the field took a dict and was stored without being
enforced; the shape and the behaviour both changed by v1.97.0.
Supporting stacked windows in profiles is [issue #1]. For fair use the shorter period is usually the binding one anyway: 100k/day already caps a user near 3M/month.
Limits are pushed to the gateway when a key is issued, and re-applied every time the owner loads the dashboard. Editing a profile's quota, or moving a user to a different profile, therefore takes effect on their next page load rather than requiring them to regenerate.
This matters because the two can disagree: the portal reads limits from its own database to render the page, while the gateway enforces whatever was last pushed to the key. Without the re-apply, the dashboard would advertise a quota that nothing enforced.
Clearing a quota sends an explicit null. LiteLLM leaves an omitted field
untouched, so a profile that loses its allowance would otherwise keep enforcing
the previous one.
The dashboard shows what a key has consumed, from two sources with different granularity:
- Per day, over 30 days — read from LiteLLM's per-request spend log, filtered by the key's SHA-256 and aggregated by the portal.
- Against the quota — read from the key's own spend counter, which is what the gateway enforces against. It resets on the budget period, so it need not agree with the 30-day chart above it.
Usage belongs to a key, not a person: regenerating a key starts the history over, and the card says so.
Two gateway-side settings affect this:
disable_spend_logs: trueswitches off the per-request log. The portal then falls back to the key's cumulative total and hides the chart, rather than reporting no usage at all.maximum_spend_logs_retention_periodmust be at least as long as the charted window (30 days), or users silently see less history than the page offers.
Passing start_date/end_date to /spend/logs returns a different shape —
daily aggregates carrying spend but no token counts. Since local models are
priced so that spend is near zero, that response looks valid and carries no
usable signal. The portal reads the raw per-request rows instead.
Keys expire, so users are warned before they do — otherwise a key dies silently in someone's pipeline.
- The dashboard shows a warning once a key is within 14 days of expiring, and an error once it has expired.
- With
SMTP_HOSTset, an email goes out at 14, 3 and 1 days before expiry. Each notice is sent at most once per key and threshold; a delivery failure leaves it pending so the next run retries. - The notice is bilingual, German first: nothing records a per-user language, so it cannot pick one. Both halves carry the expiry date and the portal link.
The thresholds are a code-level default (notify.DefaultThresholds), not an
admin setting.
Without SMTP_HOST the portal logs what it would have sent. It does not
silently pretend mail was delivered.
The interface is German by default and English on request. Resolution order:
- an explicit choice, stored in a
langcookie by the switcher in the header - the browser's
Accept-Language(so a browser set to English gets English) - German
Messages live in internal/i18n/messages.go. A test asserts every key exists
in both languages, so a partial translation cannot ship; a missing one falls
back to English rather than rendering the key.
Prometheus metrics are served on /metrics, labelled by route template so a
per-user path does not create a time series per user. In the deployment Caddy
restricts the endpoint to the monitoring host.
| Metric | Meaning |
|---|---|
aiselfservice_http_requests_total |
requests by route, method, status |
aiselfservice_http_request_duration_seconds |
latency by route |
aiselfservice_key_operations_total |
key issue/extend/revoke by outcome |
aiselfservice_active_keys |
keys currently issued |
aiselfservice_keys_expiring_7d |
keys expiring within a week |
| Method | Path | Description |
|---|---|---|
GET |
/ |
User dashboard |
GET |
/login |
Redirect to OIDC provider |
GET |
/callback |
OIDC authorization code callback |
POST |
/logout |
Clear session and redirect to OIDC logout |
POST |
/backchannel-logout |
OIDC back-channel logout endpoint |
GET |
/session/status |
Returns 200 / 401 (used by client-side polling) |
POST |
/key/generate |
Generate (or replace) the user's API key |
POST |
/key/extend |
Move the key expiry to a full period from now |
POST |
/key/delete |
Delete the user's API key |
GET |
/admin |
Admin panel |
POST |
/admin/profiles |
Create a profile |
POST |
/admin/profiles/{id} |
Update a profile |
POST |
/admin/profiles/{id}/delete |
Delete a profile |
POST |
/admin/users/{id}/profile |
Assign a profile to a user |
POST |
/admin/users/{id}/key/revoke |
Revoke another user's API key |
POST |
/lang |
Record an explicit language choice |
GET |
/healthz |
Liveness probe |
GET |
/readyz |
Readiness probe (checks the database) |
GET |
/metrics |
Prometheus metrics |
Register the application with your OIDC provider:
- Redirect URI:
{FRONTEND_URL}/callback - Post-logout redirect URI:
{FRONTEND_URL}/(optional) - Back-channel logout URI:
{FRONTEND_URL}/backchannel-logout(optional) - Required scopes:
openid,email,profile
- Go with chi router
- Key issuance behind a
keyprovider.Providerinterface; LiteLLM is one adapter - bun ORM over SQLite
- coreos/go-oidc for OIDC/OAuth2
- Server-rendered HTML templates (no JavaScript framework)
MIT — Copyright (c) 2026 virtUOS, Osnabrück University