Aquarium is a live glass box for the LLM inference path. You chat on the left. On the right the machinery works in the open: the request path drawn in motion, a log of every event that carried the conversation, and a narrator that explains each hop. It sits in front of the LiteLLM gateway you already run — nothing new goes into the inference path — and shows what happens to every request: what gets checked, what gets cached, what the guard decided, and how many tokens the model actually spent.
The top two rows are the runtime — the boxes a request actually passes through. The bottom row is the glass box itself: the parts that make the machinery visible. Boxes that belong to a component — the narrator, the inspector, the themes, the schema, replay — open into it: double-click a box, or press its enter button.
deploy
One container, one compose file, one config file. The container runs unprivileged for its whole life (only a chown runs as root), its dependencies are pinned, and every deployment shape is an environment variable, not a code change.
three postures
| posture | one line of config | what it is for |
|---|---|---|
| open | — (the default) | The lab, on a trusted network. Plain HTTP, no auth. |
| shared credential | AQUARIUM_AUTH=user:pass |
UI and API behind HTTP Basic. The same container serves HTTPS
when you mount a certificate (AQUARIUM_TLS_CERT /
AQUARIUM_TLS_KEY). |
| identity | front it with your proxy | SSO, OIDC/SAML, MFA via the access layer you already run — Authelia, Authentik, oauth2-proxy, Caddy, a ZTNA broker. Aquarium deliberately implements no accounts. |
One setting is not optional on a shared network:
AQUARIUM_CALLBACK_TOKEN. It gates the callback endpoint —
without it, anything that can reach the relay can inject events — and
in the open posture it gates settings writes too.
private CA, both directions
Enterprise deployments usually terminate TLS with their own CA. Aquarium has a knob for each direction:
- Serving. Mount a certificate your CA issued and
set
AQUARIUM_TLS_CERT/AQUARIUM_TLS_KEY. The entry script switches to HTTPS on its own. - Outbound. Set
AQUARIUM_CA_BUNDLEand the relay's calls to the gateway and Langfuse verify against your bundle instead of the system store. - The gateway side. The LiteLLM callback logger
accepts
AQUARIUM_CALLBACK_CAfor its POST back to the relay — same bundle, same variable name a deployment would expect.
the egress policy
The relay is an outbound proxy for your gateway, so its outbound targets are validated at config-save time and on every use:
| target | rule |
|---|---|
| loopback (127.0.0.0/8, .localhost) | refused unless AQUARIUM_ALLOW_LOOPBACK=1 — the one-line change when the gateway runs on the same box |
| link-local (169.254.0.0/16) | never — that is the cloud metadata range |
| unspecified, reserved, CGNAT (100.64.0.0/10) | refused |
| hostnames | checked against what they resolve to — anything.example pointing at 127.0.0.1 is refused like the literal address |
| RFC1918 LAN addresses | allowed — a homelab gateway on 192.168.x is the normal case |
Upstream error responses are relayed as fixed text, never as upstream body, so a re-pointed base URL can't be used to read an internal service's content through the page.
the live wire under auth
The page's event stream is SSE, and EventSource cannot carry an
Authorization header — so the page mints a wire ticket
(GET /api/wire, gated exactly like the rest of the API)
and opens /api/events with it. Tickets are bound to the
session they were minted for, expire in two hours, and a stalled
subscriber's queue is bounded — drop-oldest, so nobody can wedge the
relay. /api/health reports counts only, never session
ids.
data and limits
- One file:
data/config.json(mode 600) holds the gateway and Langfuse credentials and the optional browser defaults. Back it up; it is the deployment's memory. Wipe it to factory-reset. - Every request body is capped at 2 MB, streamed — the cap bites before the body is buffered.
- Strict headers on every response: a same-origin CSP, nosniff, frame denial under auth, and HSTS when TLS is on.
the turn document
One document shape, three producers. The chat pane, the Langfuse
reader, and file import all emit the same events —
aquarium.turn.v1 — and the viewer consumes nothing else.
A session is portable JSON: export one from the running app, import
it anywhere, or sweep it straight from Langfuse. Replayed turns render
through the same code path as live ones.
events
| event | when it happens |
|---|---|
request_sent | the request leaves the relay for the gateway, with its full message list |
delta | a streaming chunk arrives (streaming transport only), with its parsed content |
response_complete | the response settles, with usage and timing (wall time, TTFT) |
error | a leg fails; the error text is carried, never the upstream body |
callback_received | the gateway's logging payload arrives on the wire — guard verdict, cache state, trace reference |
trace_ref | a trace reference lands, binding the turn to the trace store |
session_open / turn_start / turn_end | the session and turn boundaries the viewer groups everything into |
provenance
Every narrator step carries an honesty label, because the diagram shows more than the wire says:
| label | meaning |
|---|---|
| observed | backed by events in the log — the wire says so |
| asserted | declared by the topology — you said your deployment looks like this; the wire can't confirm it |
| inferred | deduced from evidence — the narrator shows its evidence |
A node the wire never mentions reports that honestly in the inspector, instead of inventing numbers.
gallery
The app, running. Captions say what is on screen; the inspector and the narrator are in every shot.