Setup
Self-hosted as a single container per deployment: one Fastify process serves the API and the built web UI on one port. docker compose up brings up Postgres, the rating worker, and the app in one command; an npm path stays available for development.
Quick start · Docker (single-tenant self-host)
One container serves the API and the web UI on one port, so chat data never leaves adopter infra. The app service waits for Postgres, then its entrypoint runs migrations, seeds the lineup, and starts the server.
cp .env.example .env # set GOOGLE_API_KEY and MATCHUP_TOKEN_SECRET
docker compose up
Open http://localhost:3001 — web UI and /api/... share the same origin and port (PORT, default 3001). The multi-stage Dockerfile builds all workspaces, ships the migration .sql files next to dist, and prunes dev deps into a slim runtime. The server serves web/dist via @fastify/static only when the bundle exists (production/Docker), with an SPA fallback to index.html for GET routes outside the API prefixes /api, /health, /v1, /models, /chat/completions, /completions, /embeddings — an unmatched path under one of those gets a JSON 404, because HTML at 200 for GET /v1/models makes an OpenAI client report a mimetype error instead of a missing route. Override the bundle location with WEB_DIST_DIR.
Quick start · npm (development)
Postgres + worker in Docker, Node/Vite dev servers on the host for hot reload. The bundle is absent, so Vite serves the UI on :5173 and same-origin static serving stays off.
cp .env.example .env # configure the seeded provider
npm install
docker compose up -d postgres worker # Postgres 16 + rating worker
npm run db:migrate --workspace server
npm run db:seed --workspace server
npm run dev # server :3001 · web :5173
Open http://localhost:5173; Vite proxies /api and /health to :3001.
Workspaces
An npm workspace monorepo. The root workspaces array is in build order — server, packages/react-sdk, then web — so the SDK builds before the demo that depends on it.
| Workspace | Path | Notes |
| @omni-arena/server | server/ | Fastify API. New dep @fastify/websocket powers the /api/arena/control control plane. |
| @omni-arena/react | packages/react-sdk/ | Published headless React SDK — chat, vote, leaderboard, and analytics hooks plus their framework-free helpers. The demo consumes it. See SDK. |
| @omni-arena/web | web/ | Vite + React demo; imports @omni-arena/react (workspace *). |
| omniarena-rating | worker/ | Python rating worker (not an npm workspace). |
e2e/,
examples/*, and
integrations/* are
not workspaces either: each has its own
package.json and lockfile and is installed on demand by its own script, so a plain root
npm install stays fast and none of them can break the published build. See
Third-party UI integrations.
Environment variables
| Variable | Required | Purpose |
| GOOGLE_API_KEY | Google models | Gemini API key (aistudio.google.com/apikey) |
| OPENAI_API_KEY / OPENAI_BASE_URL | OpenAI models | OpenAI bearer token; optional compatible base URL |
| OLLAMA_BASE_URL | no | Defaults to http://localhost:11434; provider is always registered |
| VLLM_BASE_URL / VLLM_API_KEY | vLLM models | OpenAI-compatible endpoint + optional bearer token |
| HOST_PROXY_URL / HOST_PROXY_TOKEN | host-proxy models | Host-owned OpenAI-compatible endpoint + optional proxy auth |
| HARNESS_VERSION | no (v1) | Version label persisted on every matchup |
| REFIT_INTERVAL_SECONDS | no (300) | Rating worker loop interval between refits |
| FULL_REFIT_EVERY | no (12) | Refits between from-scratch (cold) fits in loop mode; 0 warm-starts indefinitely |
| RATING_RIDGE | no (0.01) | Rating worker ridge-prior strength (log-odds scale) |
| STYLE_RIDGE | no (0.05) | Ridge-prior strength for the style-controlled fit |
| LOG_LEVEL / LOG_FORMAT | no (INFO / text) | Rating worker log level and format (text or json) |
| MATCHMAKER | no (smart) | smart (under-sampled / high-variance pairs) or random (uniform) |
| DATABASE_URL | yes | Postgres connection. npm dev: host localhost. Docker Compose: app/worker override the host to postgres |
| MATCHUP_TOKEN_SECRET | production | HMAC secret, min 16 chars; insecure dev default otherwise |
| PORT | no (3001) | Port the app listens on / publishes; web UI + API share it |
| WEB_ORIGIN | no (:5173) | Allowed CORS origin (moot when the single container serves both same-origin) |
| WEB_DIST_DIR | no | Override the built web bundle dir; default ../../web/dist is correct for the repo and image |
| ARENA_MOCK_PROVIDER | no | Set 1/true to register the deterministic mock provider for demos/examples/e2e (no LLM key). Off by default. |
| ARENA_TRIGGER | no (always) | When the arena engages: always, manual, or sampled. Any other value fails at boot. See Trigger and exposure. |
| ARENA_SAMPLE_RATE | ARENA_TRIGGER=sampled | Engagement probability in [0, 1] (default 0). Ignored for always/manual. Out-of-range values fail at boot. See Trigger and exposure. |
| ARENA_EXPOSURE | no (blind) | What the user sees when engaged: blind (both answers streamed, votable) or shadow (only incumbent streamed; challenger persisted silently; not votable). Requires ARENA_DEFAULT_MODEL when shadow. See Trigger and exposure. |
| ARENA_DEFAULT_MODEL | ARENA_TRIGGER ≠ always, or ARENA_EXPOSURE=shadow | Enabled model for single rounds and as the shadow incumbent: UUID, provider_model_id slug, display_name, or provider:provider_model_id. Resolved at boot. See Trigger and exposure. |
| ARENA_JOIN_WINDOW_MS | no (2000) | Rendezvous window for slot join: how long the first of two sibling requests waits for its pair. 0 disables joining (a joinKey is then ignored); max 60000. |
| ARENA_JOIN_MAX_PENDING | no (256) | Cap on unpaired join scopes held in memory; over it a join is refused with join_unavailable rather than queued. Min 1, max 100000. |
| ARENA_JOIN_MAX_QUEUED_EVENTS | no (4096) | Per-connection event backlog on a joined matchup: events that may queue for one slot whose client reads slower than the model produces. Over it that one connection fails instead of growing without bound. Min 16, max 1000000. |
.env.example lists every variable the server and the rating worker read; the table above expands on them with bounds and semantics. The default seed contains Google models, so it needs GOOGLE_API_KEY; change provider + provider_model_id in the seed for OpenAI, Ollama, vLLM, or host proxy. The root .env loads regardless of workspace cwd.
All
ARENA_JOIN_* values are coerced and range-checked at boot (
server/src/arena/join.ts), so a non-numeric or out-of-range value throws before the server listens. Compose's
app service loads the repo-root
.env wholesale; its
worker service instead forwards each variable the rating worker reads explicitly (see
Rating worker).
Host-proxy contract
Expose OpenAI-compatible streaming chat completions at <HOST_PROXY_URL>/chat/completions. OmniArena sends model, the server-derived linear messages, stream: true, and x-omni-arena-proxy: 1. The host keeps the upstream provider credential.
Trigger and exposure
By default every request to POST /api/arena/chat is a blind A/B matchup. Two orthogonal axes relax that: trigger (ARENA_TRIGGER — when) and exposure (ARENA_EXPOSURE — what the user sees).
| ARENA_TRIGGER | What a request gets | Votable |
| always (default) | An engaged round of two models chosen by the MATCHMAKER strategy | only when ARENA_EXPOSURE=blind |
| manual | A single round served by ARENA_DEFAULT_MODEL, unless the request opts in | only opted-in blind rounds |
| sampled | An engaged round with probability ARENA_SAMPLE_RATE, otherwise single | only engaged blind rounds |
| ARENA_EXPOSURE | Engaged behaviour | Wire mode |
| blind (default) | Both answers streamed anonymously; user can vote | matchup |
| shadow | Only the incumbent (ARENA_DEFAULT_MODEL) is streamed; a challenger ≠ incumbent runs silently; both responses + matchups.mode='shadow' persisted; no vote token | shadow |
| trigger | engaged? | exposure=blind | exposure=shadow |
| always | yes | matchup | shadow |
| sampled | hit | matchup | shadow |
| sampled | miss | single | single |
| manual | opted-in | matchup | shadow |
| manual | not | single | single |
Opting in, under manual. Either signal turns that one request into an engaged round: body arena: true, or header x-arena: on (case-insensitive, and only on counts — 1/true/yes do not). The header works on every protocol; the body field rides in each protocol's extension slot. The signals are OR-ed. Under always and sampled both are ignored. Closed enums fail at boot on typo. ARENA_DEFAULT_MODEL is required when trigger ≠ always, or when exposure is shadow.
ARENA_DEFAULT_MODEL accepts a UUID or a human identifier. Resolved at boot against the enabled roster and stored as a models.id UUID. Accepted forms (preference order): models.id UUID, provider:provider_model_id, provider_model_id slug, or display_name. A mistyped identifier throws before the server listens.
What a single round is: one slot, one model, nothing recorded. mode: "single", slots: ["A"], votable: false, no matchupToken/conversationId/turnIndex.
What a shadow round is: incumbent on A (streamed), challenger on B (silent). mode: "shadow", slots: ["A"], votable: false, no vote token. Both responses persisted with matchups.mode='shadow'. POST /api/arena/vote returns 403 Shadow matchups are not votable. Auto-judge pickup is deferred.
# Shadow canary against a fixed incumbent
ARENA_TRIGGER=always
ARENA_EXPOSURE=shadow
ARENA_DEFAULT_MODEL=gemini-3-flash-preview
# Client sees mode:shadow, votable:false, slots:["A"], no token
# Both answers land in Postgres with matchups.mode='shadow'
Database
| Task | Command | Notes |
| Migrate | npm run db:migrate --workspace server | Applies unapplied migrations/*.sql in filename order, each in a transaction. Add NNN_description.sql; never edit applied files. |
| Seed | npm run db:seed --workspace server | Disables every existing model, then upserts the lineup by (provider, provider_model_id) and re-enables those rows — so editing server/src/db/seed.ts and re-running is how you retire a model too. Currently three Google models (Gemini 3.1 Flash-Lite, 3 Flash, 3.5 Flash). |
| Seed (mock) | npm run db:seed:mock --workspace server | Disables every other model, seeds two mock models (Alpha/Beta). Pair with ARENA_MOCK_PROVIDER=1. |
| Seed (demo data) | npm run db:seed:demo --workspace server | Synthetic voting history so the insights dashboard is not empty. See below. |
Demo data
Every chart on the insights dashboard is computed from recorded votes, so a fresh install renders nothing but empty states. server/src/db/seed.demo.ts fabricates a plausible voting record against whichever models are currently enabled.
Opt-in, never automatic. Neither the container entrypoint nor docker compose up invokes it, so a fresh deployment starts with a genuinely empty arena and the dashboard shows empty states until real votes arrive.
# npm dev path (host)
npm run db:seed:demo --workspace server
npm run db:seed:demo --workspace server -- --reset --matchups 400 --days 30
# Docker path — run the compiled script directly. `tsx` is a devDependency and
# is pruned from the runtime image, so the npm script is not available there.
docker compose exec app node server/dist/db/seed.demo.js --reset
| Flag | Default | Effect |
| --matchups | 240 | Number of matchups to generate. |
| --days | 14 | Window to spread them over, ramping toward the present. |
| --reset | off | Delete previously seeded demo data first. |
| --seed | 20260724 | PRNG seed; the same value reproduces the same arena. |
Each model's personality is derived from its name — display_name plus provider_model_id, lowercased — so the seeded arena matches the intuition a reader already has about the roster. Markers must match a whole name segment, since gemini ends in mini.
| Name contains | Reads as | Gets |
| lite · mini · nano · small · tiny · 8b · haiku | the fast, cheap variant | lowest latency, terse answers, weakest ratings |
| pro · ultra · opus · max · large · 70b · thinking | the flagship | slowest, longest answers, strongest ratings |
| a version number | recency | newer versions rate higher; older ones in the same class pad their answers more |
| neither marker, no version | the middle of the roster | mid-tier defaults, ranked by declaration order |
With the default Gemini lineup that yields Gemini 3.5 Flash on top, Gemini 3 Flash second but padding its answers, and Gemini 3.1 Flash-Lite fastest by a wide margin and clearly last on quality — so the "does faster win?" scatter has a real answer.
Rao-Kupper votes
style confounding
many small sessions
backfilled rating history
Votes are drawn from the same Rao-Kupper model the worker fits, with deliberate style confounding — verbose, heavily formatted answers win more than their latent strength justifies, so the padded runner-up closes most of the raw gap and gives it all back under style control. Strength outweighs what padding can buy, so the better model still leads the raw leaderboard and style control widens its lead rather than reordering it. Votes are spread across many small anonymous sessions because the worker's anomaly screen excludes sessions resembling vote-stuffing or a one-sided bot. model_ratings, model_style_ratings, model_rating_history, and style_control_coefficients are backfilled (a win-rate approximation on the Elo-like scale, one history checkpoint per day up to twelve) so the rating and style charts work before the worker has run; a real refit overwrites them.
What --reset does and does not cover. Demo conversations carry a demo- session prefix and --reset deletes only those matchups and conversations, so votes recorded through the real UI survive. The four worker-owned tables are the exception: rows for the currently enabled models are deleted and rewritten on every run, --reset or not. Seeded matchups also carry harness_version = 'demo', the easiest way to tell them from real traffic in SQL.
Mock provider
A deterministic stub provider (
server/src/providers/mock.ts) streams fixed per-model tokens with no network, so a full round-trip (stream → vote → reveal) runs with no LLM key. Enable with
ARENA_MOCK_PROVIDER=1 (registers the
mock provider; off by default so it never shadows real providers) and seed with
db:seed:mock. Both
examples and the e2e suite use it.
docker compose up -d postgres
ARENA_MOCK_PROVIDER=1 npm run db:migrate --workspace server
ARENA_MOCK_PROVIDER=1 npm run db:seed:mock --workspace server
ARENA_MOCK_PROVIDER=1 npm run dev --workspace server # :3001
Rating worker
The Bradley-Terry rating worker lives in worker/ (Python, NumPy/SciPy). It screens anomalous voting sessions, aggregates votes in-database, fits ratings with Fisher-information CIs, and upserts model_ratings. --style adds the heavier style-controlled pass into model_style_ratings.
cd worker
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# One-shot (needs migrations applied + DATABASE_URL):
DATABASE_URL=postgres://omni_arena:omni_arena@localhost:5432/omni_arena \
python -m omniarena_rating
# Add the heavier style-controlled pass on raw votes:
python -m omniarena_rating --style
# Periodic loop with warm-started refits (add --style for the style pass):
python -m omniarena_rating --loop --interval 300 --ridge 0.01
# Same loop, but never force a from-scratch refit (default: every 12th):
python -m omniarena_rating --loop --full-refit-every 0
# Skip the pre-fit anomaly screen (keep every session):
python -m omniarena_rating --no-anomaly-filter
python -m pytest # pure-Python tests, no database
The pre-fit anomaly screen drops spam/malicious sessions via p-value tests before any fit (
--no-anomaly-filter disables it). In loop mode most refits are warm-started, but every
FULL_REFIT_EVERY refits (
--full-refit-every, default 12 — hourly at the default interval) the warm state is discarded for a from-scratch fit, which is then cross-checked against a warm-started fit of the same aggregates;
0 disables the forced pass. See the
rating methodology.
docker compose up -d also starts a
worker service running the loop against Postgres; its image's default command is
python -m omniarena_rating --loop --style, so
every refit runs both passes. Ratings appear after the first successful refit; until then the leaderboard rating fields are null. Override the service's
command: for a one-shot or default-only run.
| Compose worker service | What that gives you |
| --loop --style by default (the image CMD) | A stock docker compose up fills model_ratings, model_rating_history, model_style_ratings, and style_control_coefficients — the dashboard's style panels included — with no extra steps |
| Every worker knob forwarded | REFIT_INTERVAL_SECONDS, FULL_REFIT_EVERY, RATING_RIDGE, STYLE_RIDGE, LOG_LEVEL, LOG_FORMAT come from the repo-root .env, each defaulting to the worker's own default |
| No env_file (unlike app) | Deliberate: the worker needs no provider keys or app secrets, so the list above plus a DATABASE_URL on the compose postgres hostname is the whole of what reaches the container |
The worker fits
pairwise comparisons, so it needs voted matchups. Where most rounds are non-votable
single rounds — which persist no matchup — there is nothing to aggregate and the rating fields stay null however often the loop runs. See
Rating methodology → what the engine cannot rate.
Commands
| Command | What it does |
| npm run dev | Server (tsx watch) + web (Vite), concurrently |
| npm test | npm run test --workspaces — server (Vitest + pg-mem), react-sdk (Vitest + jsdom hook tests), web (no tests) |
| npm test --workspace @omni-arena/react | Just the SDK hook tests |
| python -m pytest | Rating worker numerical tests (run in worker/) |
| npm run build | All workspaces in order: server tsc → react-sdk tsc → web bundle |
| npm run typecheck | All workspaces |
| npm run e2e | Builds both example apps, boots OmniArena (pg-mem + mock provider) + example servers, drives the full arena flow in headless Chromium (Playwright) |
Tests need no running Postgres or API keys. The hook tests moved from web/ into the SDK and now sit beside the framework-free modules they cover (protocol, session, stream, vote, one file per hook); they stub fetch with real SSE streams and stub localStorage (Node 24's experimental global shadows jsdom's).
Two cross-cutting server suites. blindness.test.ts asserts that no model identity reaches the wire in any of the five protocols, on a single connection and on both siblings of a joined matchup — cases generated from the protocol registry, so a new adapter is covered by default. arena/join.test.ts covers slot join: simultaneous siblings elect exactly one leader, a third claim is refused, the pending set stays bounded, and slot B still finishes and is persisted after its own consumer disconnects.
Gotcha: run tests per workspace, not a bare vitest at the repo root. The jsdom environment is set per workspace (each vitest.config.ts) and there is no root Vitest config, so a root-level vitest uses the default Node env and the SDK hook test fails with ReferenceError: document is not defined. Use npm test (delegates to npm run test --workspaces) or npm test --workspace <name> — both pass.
Reference examples
Both talk to a running server (use the mock provider for a zero-key run). See the
integration guide for how each adapter maps to these apps.
Third-party UI integrations
Beyond the purpose-built examples, integrations/ wires the arena into real upstream chat UIs at pinned revisions. Each directory is self-contained — its own package.json, lockfile, tests, and README — and outside the root workspace, so a plain root install neither installs nor builds them.
| Directory | Upstream | Protocol | How to run it (from that directory) |
| vercel-ai-chatbot | vercel/ai-chatbot template | vercel-ai | npm install · npm run setup · npm test |
| assistant-ui | assistant-ui monorepo, examples/with-ag-ui | ag-ui | npm install · npm run setup · npm test |
| open-webui | Open WebUI container image (v0.10.2) | openai | npm install · docker compose up -d · npm run arena (blocks), then npm test |
Pinned clone + overlay. The two Node integrations share one model:
upstream.json records the exact upstream commit,
.upstream/ holds the gitignored clone,
overlay/ holds the arena-layer sources copied in verbatim, and
scripts/overlay.mjs applies anchored patches to upstream's own files — each anchor must match exactly once, so a moved upstream line fails setup loudly instead of producing a half-integrated app. Open WebUI runs from its published image and reaches the arena through a small OpenAI-compatible bridge (
bridge/). All three run key-free against the
mock provider; see the
integration guide for what each protocol can and cannot carry.
End-to-end tests
npm run e2e (orchestrated by e2e/run.mjs) is deterministic and CI-friendly — no real keys or external LLM calls. It installs harness + example deps, builds each example (a smoke check), installs Playwright Chromium, then boots the OmniArena harness (pg-mem + mock) and both example servers and drives the full flow. protocol.spec.ts asserts the raw vercel-ai/ag-ui wire streams (Vercel path is votable, leaderboard updates); examples.spec.ts drives both UIs through stream → vote → reveal. Both share the expected roster and per-model output fingerprints from arena-fixtures.ts.
Continuous integration
.github/workflows/ci.yml runs on pushes to
main and every pull request, cancelling in-flight runs for the same ref.
No job needs a secret — every suite uses the mock provider and in-memory
pg-mem — so CI works unchanged on forks. See
CONTRIBUTING.md for the contribution workflow.
| Job | Steps |
| Node | npm ci → build --workspaces → typecheck --workspaces → the three workspace test suites. Build runs first because web typechecks against the SDK's emitted declaration files. |
| Python | pip install -r worker/requirements.txt → python -m pytest in worker/ |
| End-to-end | npm ci at the root and in e2e/ → playwright install --with-deps chromium → npm run e2e; Playwright artifacts uploaded on failure |
CI pins Node 22 and Python 3.11. Locally the floor is lower — root engines asks for Node >=20, the worker for Python >=3.10 — so a green local run on Node 20 is possible while CI exercises 22.