# llm-proxy
[](https://hub.docker.com/r/yoonaowo/llm-proxy-backend) [](https://hub.docker.com/r/yoonaowo/llm-proxy-frontend) [](https://github.com/elleqt/llm-proxy-backend/actions/workflows/ci.yml)
A self-hosted gateway that lets a team share Claude (Pro/Max) and ChatGPT (Plus/Pro) subscriptions, and any OpenAI-compatible API, through personal API keys. It embeds [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) as a Go library and adds what a shared deployment needs: users and sign-in (local accounts or any OIDC provider), self-service API keys, per-user model access rules, **per-user spend limits**, a web admin panel, a usage ledger with estimated cost, and Prometheus metrics.
> **Spend limits.** Cap what each person may spend per time window, with up to 10 windows at once: e.g. $10 per 2 hours **and** $30 per 24 hours, both counting at the same time. Set one default for everyone, then give any user their own limits or none at all. Users see how much of each limit they have used and when it resets; whether they also see dollars is up to you. [How it works](#spend-limits)
> **llm-proxy is one system in two repositories:** [llm-proxy-backend](https://github.com/elleqt/llm-proxy-backend) — the gateway, web API and metrics (start here to run it) · [llm-proxy-frontend](https://github.com/elleqt/llm-proxy-frontend) — the web interface: cabinet and admin panel.
## Contents
- [What it is / why](#what-it-is--why)
- [Supported providers](#supported-providers)
- [Screenshots](#screenshots)
- [How it works](#how-it-works)
- [Quick start](#quick-start)
- [First sign-in](#first-sign-in)
- [Upgrading](#upgrading)
- [Images](#images)
- [Building from source](#building-from-source)
- [Connecting clients](#connecting-clients)
- [Access control](#access-control)
- [Sign-in with an identity provider (OIDC)](#sign-in-with-an-identity-provider-oidc)
- [Production deployment](#production-deployment)
- [Administration](#administration)
- [Metrics and cost](#metrics-and-cost)
- [Configuration reference](#configuration-reference)
- [Security notes](#security-notes)
- [Development](#development)
- [Related repository](#related-repository)
- [License](#license)
## What it is / why
A plain CLIProxyAPI setup keeps API keys and access in one config file. Everything depends on the one person who can edit that file and restart the service. llm-proxy moves that work into a web interface, where users and admins do it themselves.
| Pain with a single config file | What llm-proxy does |
|---|---|
| A key leaks or a laptop is lost; you message the admin and wait | The user revokes the key in the cabinet. It stops working on the next request |
| A new device or tool needs a key; the admin edits the config and restarts | The user issues their own key. No config edit, no restart |
| Everyone can use every model | Access is set per person and per model (e.g. only ChatGPT, or only Sonnet), in the admin panel or from identity-provider groups. Changes apply to existing keys at once |
| One heavy user burns the whole subscription | Spend limits per time window (e.g. $10 per 2 hours and $30 per day), one default for everyone and any user's own limits, or none. A user over a limit is refused until the window resets |
| Onboarding and offboarding are manual | Access follows your identity provider: join the group to get in. Blocking an account cuts all its keys and sessions at once |
| One shared key; nobody knows who used what | Each key belongs to a person or a service account. Usage, cost and quota use are visible per user: users see their own usage (and cost, if you allow it), admins see everyone's, Prometheus gets the rest |
| Vendor tokens are copied onto the server by hand | Vendor accounts are added through a browser sign-in wizard |
| Cost estimates need a price list someone maintains | Prices update themselves from a public model catalog; manual overrides win |
| Nobody knows who changed what | Admin actions are written to an audit log |
Clients: Claude Code, [omp](https://github.com/can1357/oh-my-pi), any OpenAI-compatible client, and Codex-style clients (`/backend-api/codex/responses`).
## Supported providers
| Provider | How it is added | Name in access rules |
|---|---|---|
| Claude (Pro/Max subscription) | Browser sign-in wizard ([Quick start](#7-add-a-vendor-account)) | `claude` |
| ChatGPT (Plus/Pro subscription, Codex backend) | Browser sign-in wizard | `chatgpt` |
| Any OpenAI-compatible API: a hosted vendor, a router, a local Ollama or vLLM | Base URL, optional API key and models in the admin panel ([details](#openai-compatible-providers)) | the name you give it |
| OpenCode Go subscription | OpenAI-compatible provider with its Go base URL, API key and session header ([recipe](#opencode-go-with-official-deepseek-fallback)) | the name you give it |
Each kind can be added as many times as needed: several subscription accounts, several OpenAI-compatible providers. OpenAI-compatible providers are tested end to end against [DeepSeek](https://api-docs.deepseek.com/) (model discovery, chat, streaming, and the Anthropic `/v1/messages` route translated to it); other vendors that speak the same `/models` and `/chat/completions` API are expected to work the same way.
## Screenshots
| | |
|---|---|
| 
Sign-in: local password and/or identity provider | 
Cabinet: your keys, usage and spend limits |
| 
Connect: client setup with your key filled in | 
Admin: users and service accounts |
| 
Admin: one user's access rules, spend limits, keys and activity | 
Admin: vendor accounts and quota |
| 
Admin: gateway settings, default spend limits, what users see, prices | 
Cabinet in the dark theme, Russian UI |
## How it works
```mermaid
flowchart LR
clients["API clients
Claude Code, omp, OpenAI SDKs"]
browser["Browser"]
subgraph be["backend container"]
gw["Gateway listener
LLMPROXY_LISTEN_ADDR
API-key auth, policy gate"]
cpa["Embedded CLIProxyAPI"]
web["Web API listener
LLMPROXY_WEB_ADDR
/api/* cabinet, admin, OIDC"]
met["Metrics listener
LLMPROXY_METRICS_ADDR
/metrics"]
end
fe["frontend container
nginx: UI + /api proxy"]
pg[("PostgreSQL")]
vendors["Anthropic / OpenAI
subscription accounts"]
idp["OIDC provider
optional"]
prom["Prometheus"]
clients -->|"Bearer sk-..."| gw
gw --> cpa
cpa -->|"optional outbound proxy-url"| vendors
browser --> fe
fe -->|"/api/*"| web
web -.-> idp
gw --> pg
web --> pg
prom -->|scrape| met
```
The backend is one process with three listeners.
**Gateway listener** (`LLMPROXY_LISTEN_ADDR`, default `:8080`). The proxied LLM API: `/v1/models`, `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/backend-api/codex/responses` and a few more. Every request needs an llm-proxy API key. A policy gate checks the requested model against the key owner's access rules before the request reaches CLIProxyAPI. Routes the gate cannot decide on (websocket relays, realtime sessions and so on) are refused. `GET /healthz` is the only route open without a key. This is the one listener you publish, and only through a reverse proxy: its HTTP server has no header or idle timeouts (see [Production deployment](#production-deployment)).
**Web API listener** (`LLMPROXY_WEB_ADDR`, default `127.0.0.1:8081`). Serves `/api/*` for the cabinet, the admin panel and sign-in (sessions, OIDC). Only the frontend's nginx should reach it: it trusts `X-Real-IP` for rate limiting and audit. **Never expose it directly.** Set it to `off` to run the gateway without a web interface.
**Metrics listener** (`LLMPROXY_METRICS_ADDR`, default `127.0.0.1:9090`). Serves `/metrics` for Prometheus, with no authentication. **Never expose it.** Only a scraper inside your network should reach it.
In both compose files only the gateway (host port `8080`) and the frontend (host port `8081`) are published. In the full `docker-compose.yml` the web API listens only on the `web` network, and metrics only on the `metrics` network; the minimal file puts all three containers on one network and leaves metrics on the backend's loopback.
## Quick start
Ready-made images for `linux/amd64` and `linux/arm64` are published on Docker Hub; nothing is built on your machine. You need Docker with the Compose v2 plugin, and openssl for the database password and the credentials key.
### 1. Install Docker
- **Linux:** use Docker's convenience script or your distribution's instructions ([docs.docker.com/engine/install](https://docs.docker.com/engine/install/)):
```sh
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER" # then log out and back in
```
- **macOS / Windows:** install [Docker Desktop](https://docs.docker.com/desktop/).
Check both:
```sh
docker --version
docker compose version # must work: the Compose v2 plugin is required
```
### 2. Make a directory for the stack
```sh
mkdir llm-proxy && cd llm-proxy
```
### 3. Create `docker-compose.yml`
There are two versions of the compose file. Both run the same images and start the same stack; pick one.
**Minimal** (the shortest path): only what the stack needs to start and to let you sign in over plain http. Download it as `docker-compose.yml`:
```sh
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/elleqt/llm-proxy-backend/main/docker-compose.minimal.yml
```
or save this as `docker-compose.yml` (it is the same file):
```yaml
# llm-proxy, minimal version: only what the stack needs to start and to sign in
# over plain http. docker-compose.yml has every setting, with comments.
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: llmproxy
# CHANGE ME: the same value as PGPASSWORD below. Postgres takes it only on its
# first start, with an empty volume.
POSTGRES_PASSWORD: change-me-db-password
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "llmproxy"]
interval: 5s
timeout: 3s
retries: 10
backend:
# Keep one version for both images.
image: yoonaowo/llm-proxy-backend:0.8.4
environment:
LLMPROXY_DATABASE_URL: postgres://llmproxy@postgres:5432/llmproxy?sslmode=disable
# CHANGE ME: the same value as POSTGRES_PASSWORD above.
PGPASSWORD: change-me-db-password
# CHANGE ME (openssl rand -hex 32), best before adding vendor accounts: the
# placeholder works but is public, and the backend warns while it is set. The key
# encrypts the vendor accounts in the database; changing or losing it means
# signing them in again.
LLMPROXY_CREDENTIALS_KEY: change-me-credentials-key-at-least-32-bytes
LLMPROXY_WEB_ADDR: ":8081"
LLMPROXY_PUBLIC_API_URL: http://localhost:8080
LLMPROXY_COOKIE_SECURE: "false"
LLMPROXY_BOOTSTRAP_ADMIN_EMAIL: admin@example.com
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
frontend:
image: yoonaowo/llm-proxy-frontend:0.8.4
ports:
- "8081:8080"
volumes:
pgdata:
```
**Full**: [`docker-compose.yml`](docker-compose.yml) in this repository. Use it for a production deployment: it keeps the web API and the metrics on their own Docker networks, exposes `/metrics` to a scraper, and writes every setting out with a comment, defaults as values and optional settings (OIDC and others) as commented-out examples. Download it instead of the minimal file:
```sh
curl -fsSLO https://raw.githubusercontent.com/elleqt/llm-proxy-backend/main/docker-compose.yml
```
No `.env` file is needed with either: every value is in the compose file, and you change a setting by editing it there. `CHANGE ME` marks what to change; either file also starts unchanged (see step 4). Both files use the same volume, user and database names, so you can switch from the minimal file to the full one later and keep your data: put the database password and the credentials key you already use into the new file instead of new ones.
### 4. Set the database password and the credentials key
The compose file starts as downloaded, which is enough to try the stack on your own machine. Its placeholders are public, though, so for anything else replace `change-me-db-password` (it appears twice: Postgres and the backend) and `change-me-credentials-key-at-least-32-bytes` with random values:
```sh
sed -i -e "s/change-me-db-password/$(openssl rand -hex 24)/g" \
-e "s/change-me-credentials-key-at-least-32-bytes/$(openssl rand -hex 32)/" docker-compose.yml
```
On macOS:
```sh
sed -i '' -e "s/change-me-db-password/$(openssl rand -hex 24)/g" \
-e "s/change-me-credentials-key-at-least-32-bytes/$(openssl rand -hex 32)/" docker-compose.yml
```
Best do this before the first start: Postgres sets the password only when it creates the database in an empty volume, so changing it later in the file alone leaves the database on the old one, and the backend can no longer connect. The credentials key encrypts the vendor accounts stored in the database, so replace it at the latest before adding the first vendor account: accounts added under one key must be signed in again after a change. With the placeholder anyone who gets a copy of the database can read them, and the backend logs a warning on every start while it is set. Keep a copy of your key with your other secrets, apart from database backups. Without it the accounts cannot be read and must be signed in again (see [Backups](#production-deployment)).
**Already started with the placeholders?** If there is nothing to keep, `docker compose down -v` deletes the stack and its data: replace the placeholders as above and start again. To keep the data, change the database password in Postgres first, then in the file, and restart (on macOS `sed -i ''`):
```sh
db_password="$(openssl rand -hex 24)"
docker compose exec postgres psql -U llmproxy -c "ALTER USER llmproxy PASSWORD '$db_password'"
sed -i -e "s/change-me-db-password/$db_password/g" \
-e "s/change-me-credentials-key-at-least-32-bytes/$(openssl rand -hex 32)/" docker-compose.yml
docker compose up -d
```
Vendor accounts added under the placeholder key cannot be read under the new one: the backend stops at start and names the account. Delete them and sign them in again, as under *Lost or changed key* in [Backups](#production-deployment).
### 5. Start
```sh
docker compose up -d
```
Compose pulls the images and starts Postgres, the backend and the frontend. Database migrations run when the backend starts. `docker compose ps` shows all three as `healthy` once they are up (with the minimal file, which gives the backend no healthcheck, the backend as `Up`).
### 6. Sign in as the bootstrap administrator
On its first start the backend creates the administrator and prints a one-time temporary password (details and recovery: [First sign-in](#first-sign-in)):
```sh
docker compose logs backend | grep -A1 'account:'
```
Open **http://localhost:8081**, sign in with that email and password, and choose a new password. Then give yourself model access: your administrator starts with **no model access**. Open **Admin → Users**, select your account and add a rule such as `claude:*` or `chatgpt:*` (see [Access control](#access-control)).
### 7. Add a vendor account

1. Go to **Admin → Providers → Add provider** and choose `claude` or `chatgpt`.
2. Open the sign-in link and sign in to the vendor with the subscription account you want to share.
3. At the end, the browser goes to a `localhost` URL that **does not load**. This is expected. The vendor's OAuth client is registered for CLI tools, which run a small listener on your own machine to catch that redirect. llm-proxy runs on a server and opens no such listener. The authorization code is in the URL itself.
4. Copy the **whole URL** from the address bar, paste it into the wizard, and click **Finish adding**.
The link is valid for 5 minutes. If the pasted URL is wrong, the sign-in stays open and you can paste again. The vendor account's OAuth credentials are stored in Postgres, encrypted with `LLMPROXY_CREDENTIALS_KEY`.
### 8. Issue a key and test it
In the **Cabinet**, click **Issue a key**, give it a label, and copy the key. It is shown only once.
```sh
curl http://localhost:8080/v1/models \
-H "Authorization: Bearer sk-..."
```
The list contains only the models your rules allow. An empty list usually means no vendor account is added yet or your policy has no rules.
## First sign-in
### The bootstrap administrator
- **When it is created.** At every start the backend checks whether any administrator exists (blocked ones count). Only while none does, it creates a local administrator with the email in `LLMPROXY_BOOTSTRAP_ADMIN_EMAIL` (default `admin@example.com`; set it empty to turn this off). Once an administrator exists, the variable does nothing: changing it later creates no second administrator.
- **If the email already belongs to an account** that is not an administrator, the backend refuses to start with an error rather than promote it.
- **The temporary password** is printed once, to the backend's output, never through the logger:
```text
=================== llm-proxy: bootstrap administrator ===================
account: admin@example.com
temporary password:
Shown this once. Sign in on the web interface and choose a new password.
===========================================================================
```
```sh
docker compose logs backend | grep -A1 'account:'
```
It is stored only as a hash and stays in the container log until the container is recreated (for example by `docker compose up -d` after an upgrade). The bootstrap password does **not** expire; every temporary password an administrator issues later (new users, resets) expires after **72 hours**. An expired temporary password no longer signs in.
- **Forced password change.** A session opened with a temporary password can do nothing but change the password. After the change the account works normally.
- **Model access.** Every new administrator starts with an empty policy: add a rule for yourself in **Admin → Users** before issuing keys.
- If `LLMPROXY_LOCAL_LOGIN=false`, the bootstrap administrator cannot sign in with the password; the backend warns about it at start. Keep local sign-in on until another way in works.
### Lost access
While the stack is running, the backend's command line issues a new temporary password for any local account (people, not service accounts):
```sh
docker compose exec backend gateway reset-password admin@example.com
```
It prints the new temporary password once, valid for 72 hours:
```text
======================= llm-proxy: password reset ========================
account: admin@example.com
temporary password:
expires: 2026-09-26T09:15:56Z
Shown this once. Sign in on the web interface and choose a new password.
===========================================================================
```
The account must change the password at the next sign-in, is signed out everywhere and its sign-in lockout is cleared; its API keys keep working. The reset is recorded in the audit log. The command refuses (exit code `1`, reason on stderr) an unknown email, a service account and a blocked account. `gateway reset-password --unblock ` also unblocks the account, but only when it is an administrator and no other administrator is active (the only administrator was blocked); any other blocked account is unblocked by an administrator in the web interface. With local sign-in off (`LLMPROXY_LOCAL_LOGIN=false`, or the web listener off), the command still prints the password but warns on stderr that it cannot be used until local sign-in is turned on.
Use it when:
- the bootstrap password scrolled out of the log or the container was recreated before the first sign-in;
- a temporary password expired;
- the only administrator forgot their password.
With a second administrator, **Admin → Users → reset password** does the same from the web interface. Keeping two administrators is the simplest insurance.
### More administrators
- **Promote an existing person:** **Admin → Users**, open the user and change the role to administrator. Nobody can demote or block themselves, so an installation always keeps the administrator doing the edit.
- **Create one:** **Admin → Users → create**, role administrator, with a local password (a 72-hour temporary password is shown once) or an identity-provider invitation.
### Administrators and the identity provider
An account is linked to an identity-provider login in one of two ways, never by a matching email alone:
- **Invitation.** A user created with an invitation, or any person whose user page shows **renew invitation** (also your own bootstrap administrator), gets an invitation for their email that lasts 72 hours. The first OIDC sign-in whose provider reports that email **as verified** claims it and links the login to the account for good, role included. The group check (`LLMPROXY_OIDC_REQUIRED_GROUP`) still applies.
- **Sign-up** (`LLMPROXY_OIDC_ALLOW_SIGNUP=true`) creates a new, non-administrator account; promote it afterwards. A sign-up whose email already belongs to another account is refused.
With `LLMPROXY_OIDC_GROUP_POLICY` set, a linked account's model rules come from its groups at every sign-in, administrators included. The role never comes from the identity provider.
## Upgrading
1. **Back up Postgres first** (see [Backups](#production-deployment)): migrations run automatically when the backend starts and are not rolled back by going back to an older image.
2. In `docker-compose.yml`, change the tag of both images, `yoonaowo/llm-proxy-backend` and `yoonaowo/llm-proxy-frontend`, to the new release (they are released together, always use one version for both). The current release is on the [releases page](https://github.com/elleqt/llm-proxy-backend/releases) and the [Docker Hub tags page](https://hub.docker.com/r/yoonaowo/llm-proxy-backend/tags). With `X.Y.Z` the new version:
```sh
sed -i -E 's#(yoonaowo/llm-proxy-(backend|frontend)):[^ ]+#\1:X.Y.Z#' docker-compose.yml
```
(on macOS: `sed -i '' -E …`).
3. Pull and restart:
```sh
docker compose pull && docker compose up -d
```
If a migration fails, the backend does not start; `docker compose logs backend` shows why. Release notes are on the [releases page](https://github.com/elleqt/llm-proxy-backend/releases). A newer compose file is not required for an upgrade unless the release notes say so.
### Explicit reasoning levels are validated by the SDK
With CLIProxyAPI v8.0.53, OpenAI-compatible requests to an administrator-managed provider return HTTP 400 when an explicit reasoning level is outside the model's configured list. Previously, standard unsupported levels could be replaced with the nearest listed level: `max` against `none/high` became `high`. Clients must now request a supported level, or administrators must include it only when the vendor accepts it.
The **Reasoning levels** setting declares supported model capabilities to CLIProxyAPI; it does not select the effort for a request, enable a vendor feature, or define a numeric thinking budget. Clients choose `reasoning_effort`. The gateway declares its default set, including `max`, when no custom list is saved, so leaving the setting untouched does not enable unrestricted passthrough. A custom value is useful only if the vendor already accepts it. Other protocols and special modes follow the SDK's translation rules; built-in subscription providers keep the SDK's model-specific behavior.
This list is not discovered automatically from the vendor and is not advertised by the ordinary OpenAI `/v1/models` response. The SDK can include supported levels in its separate Codex-client catalog, subject to that client's supported vocabulary. Declaring a custom name does not teach the SDK how to translate it into a numeric budget.
### CLIProxyAPI v8 drops the Codex `identity-confuse` setting
The embedded CLIProxyAPI moves to v8, which removes the `identity-confuse` key of the `codex:` section. Before upgrading, remove it from the settings document in the admin panel (**Admin → Settings**) if the document sets it: the backend refuses a document with a key upstream does not know, so one that still sets it stops the backend at start.
The settings document keeps upstream's flat layout. v8 also reads a nested one (`config-version: 8`, `server:`, `oauth:`, `observability:` …), which the settings editor refuses.
### The credentials import and the `grants` volume are removed
v0.2.0 moved the vendor accounts' OAuth credentials from files in the `grants` volume into Postgres, importing the files once on its first start. This release removes that import and the `grants` volume, and refuses a settings document that sets `save-cooldown-status`, `request-log` or `error-logs-max-files`. Upgrading:
1. An installation on a release before v0.2.0 must first upgrade to v0.2.0 (following its upgrade notes on the [releases page](https://github.com/elleqt/llm-proxy-backend/releases), which add `LLMPROXY_CREDENTIALS_KEY`) and start it once: its log reports `imported the vendor credential files into the database` with the count. Only then upgrade further and remove the `grants` volume. Skipping it leaves the accounts unimported, and removing the volume deletes their only copy. This release refuses to start on a database that has users but never ran the import: its migration fails with `upgrade to v0.2.0 and start it once before this one`.
2. Before upgrading, remove `save-cooldown-status`, `request-log` and `error-logs-max-files` from the settings document in the admin panel (**Admin → Settings**) if it still sets one: a document that sets one now stops the backend at start.
3. After the upgrade, remove the `grants` mount and volume from your compose file (the shipped compose files no longer have them), run `docker compose up -d`, then `docker volume rm _grants`.
## Images
| Image | Docker Hub |
|---|---|
| backend | [`yoonaowo/llm-proxy-backend`](https://hub.docker.com/r/yoonaowo/llm-proxy-backend) |
| frontend | [`yoonaowo/llm-proxy-frontend`](https://hub.docker.com/r/yoonaowo/llm-proxy-frontend) |
**Platforms:** every tag is one multi-arch manifest for `linux/amd64` and `linux/arm64`, so the same compose file runs on x86-64 servers and PCs, a Raspberry Pi 4 or 5 with a 64-bit OS, Apple Silicon Macs (Docker Desktop) and ARM servers. Docker picks the right one.
**Tags:**
| Tag | Meaning |
|---|---|
| `X.Y.Z` | One release, never moves. **Recommended**: the compose files name one |
| `X.Y` | The newest patch release of `X.Y` |
| `X` | The newest release of major version `X`; published from `1.0.0` on |
| `latest` | The newest release |
| `edge` | The current `main` branch: untested between releases, not for production |
| `sha-` | One `main` commit |
Images carry OCI labels (source, revision, version) and an SBOM and provenance attestation; the backend reports its version in the `llmproxy_build_info` metric.
## Building from source
For development, or to run unreleased changes: clone both repositories side by side (the build override builds the frontend from `../frontend`, so the directory names matter) and add `docker-compose.build.yml` to the compose command:
```sh
mkdir llm-proxy && cd llm-proxy
git clone https://github.com/elleqt/llm-proxy-backend.git backend
git clone https://github.com/elleqt/llm-proxy-frontend.git frontend
cd backend
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
The override works on top of `docker-compose.minimal.yml` too. It tags the images `llm-proxy-backend:local` and `llm-proxy-frontend:local`, so a source build never passes for a published image. Pass both `-f` files to every later command for this stack (`logs`, `down`, …), or `export COMPOSE_FILE=docker-compose.yml:docker-compose.build.yml` in your shell. Both placeholders work for a local build: Postgres is not published, and the backend only warns about the public credentials key (see [step 4](#4-set-the-database-password-and-the-credentials-key)). To update: `git pull` in both checkouts and run the same `up -d --build`.
## Connecting clients

The web UI's **Connect** page shows the same snippets with your key and your deployment's API URL filled in. Below, `https://llm-proxy.example.com` is `LLMPROXY_PUBLIC_API_URL`.
**Claude Code**: the base URL has no `/v1`.
```sh
export ANTHROPIC_BASE_URL="https://llm-proxy.example.com"
export ANTHROPIC_AUTH_TOKEN="sk-..."
claude
```
**omp**: override the base URL of the built-in `anthropic` and `openai-codex` providers in `~/.omp/agent/models.yml`. omp keeps its own model list and features; only the address and the key change. Keep only the provider(s) your rules allow:
```yaml
providers:
anthropic:
baseUrl: https://llm-proxy.example.com
apiKey: "sk-..."
api: anthropic-messages
authHeader: true
compat:
supportsEagerToolInputStreaming: true
openai-codex:
baseUrl: https://llm-proxy.example.com/backend-api
apiKey: "sk-..."
authHeader: true
```
**OpenAI-compatible clients**: use `https://llm-proxy.example.com/v1` as the base URL and the key as the API key.
```sh
curl https://llm-proxy.example.com/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model": "gpt-5", "messages": [{"role": "user", "content": "Hello"}]}'
```
The gateway accepts the key as `Authorization: Bearer`, `X-Api-Key` or `X-Goog-Api-Key`. A `401` means the key is wrong, revoked, or its owner is blocked. A `403` means the key works but your rules do not cover that model, or, while spend limits apply to you, that the model `has no price` (the cabinet lists such models as unpriced). A `429` naming a spend limit means a window of yours is exhausted: it says when it resets, and `Retry-After` counts down to then.
## Access control

Access belongs to the **user**, not to the key. A user's policy is a list of allow rules of the form `:`:
```text
chatgpt:* every ChatGPT model, including ones released later
claude:claude-sonnet-* every Claude Sonnet model
claude:claude-opus-4-? one character after "4-"
*:* everything
```
- **Providers** are `claude` and `chatgpt` (the Codex backend is called `chatgpt` in rules), plus each [OpenAI-compatible provider](#openai-compatible-providers) under its name. Matching is case-insensitive.
- **Globs:** `*` matches any run of characters, including `/` and `:`. `?` matches exactly one character. Everything else is literal, and the pattern must match the whole model name. The rule splits on the first colon, so model names can contain colons.
- **Wildcards are evaluated per request** against the live model catalog. `chatgpt:*` covers a model the vendor releases tomorrow.
- **A model served by several providers** must be allowed on **all** of them. The router may choose any of them.
- **There are no deny rules.** Order does not matter. An empty policy allows nothing.
- **`/v1/models` shows only allowed models**, and `/v1/models/{model}` answers for a model that is not allowed as for one that does not exist. The cabinet shows the same list.
- **Edits apply at once** to every existing key of the user, from the next request. Nothing is cached.
The admin panel has a preview that shows which current models a rule set covers.
### Local and identity-provider accounts
- **Local accounts:** an administrator creates the user, and the system shows a temporary password once. It expires after 72 hours and must be changed at first sign-in. There is no self-registration.
- **Identity-provider accounts:** the user signs in through OIDC. An administrator can create the user in advance with an invitation for their email, which the first sign-in claims (72 hours). With `LLMPROXY_OIDC_ALLOW_SIGNUP=true`, unknown users who pass the group check are created automatically. When `LLMPROXY_OIDC_GROUP_POLICY` is set, the identity provider manages federated users' policies ("managed by IdP" in the admin panel). Local accounts and service accounts are still edited by hand.
### Service accounts
A service account is a user that cannot sign in. Use it for integrations such as a team chat panel, so that usage shows up under the integration and not under a person. It has its own policy, and only administrators issue and revoke its keys. It gets its own row in every per-user metric.
### Revoking and blocking
- A user revokes their own key in the cabinet. An administrator can revoke any key. Revocation applies from the next request.
- **Blocking** a user ends all their browser sessions and makes all their keys fail from the next request. An administrator cannot block or demote themselves.
- Each owner can have at most 50 active keys.
## Sign-in with an identity provider (OIDC)
These steps work with any OpenID Connect provider (Keycloak, Authentik, Zitadel, Okta, Entra ID, …).
1. **Create a confidential client** (with a client secret) using the authorization code flow.
2. **Redirect URI:** `https://llm.example.com/api/auth/oidc/callback`, where `llm.example.com` is your web UI host. Locally: `http://localhost:8081/api/auth/oidc/callback`.
3. **Scopes:** the backend requests `openid email profile`.
4. **Groups claim:** make the provider put the user's groups into the **ID token** as a list of strings. The default claim name is `groups` (`LLMPROXY_OIDC_GROUPS_CLAIM`). Group names are compared exactly as sent, so `/llm-users` and `llm-users` are different.
5. Configure the backend in `docker-compose.yml`: the full file has the OIDC lines, commented out, in the backend's `environment` (with the minimal file, add them there). Uncomment the ones you need, fill them in, and restart with `docker compose up -d`:
```yaml
LLMPROXY_SESSION_KEY: