Docs · Architecture · API · Integration · Rating methodology · Data model · Setup · SDK

Architecture

Blind multi-turn comparisons streamed through a five-protocol egress-adapter layer (native SSE default), a WebSocket control plane, slot join for one-request-per-model clients, a published headless React SDK, signed votes, multiple providers, smart matchmaking, and an off-path Bradley-Terry rating worker with a style-controlled pass and a pre-fit anomaly screen.

System overview

PackagePathRole
@omni-arena/serverserver/Fastify API: matchmaking, dual-model streaming through the protocol-adapter layer, WebSocket control plane, slot join, voting, leaderboard, analytics
@omni-arena/reactpackages/react-sdk/Published headless React SDK: hooks over framework-free protocol/session/stream/vote modules; the demo consumes it
@omni-arena/webweb/Vite + React demo UI; the reference consumer of the SDK
omniarena-ratingworker/Python Bradley-Terry rating worker (NumPy/SciPy)
e2e/, examples/*, and integrations/* are consumer code outside the workspace — each with its own package.json and lockfile, installed on demand — so a root install never builds them and an upstream's dependency tree cannot reach the published packages. See Integrations layer.
web (React demo)
imports @omni-arena/react · Vite proxy → :3001
@omni-arena/react (SDK)
useArenaChat · useArenaVote · useArenaLeaderboard · useArena*Analytics · headless hooks
▼ HTTP + native SSE (default) · WebSocket control
Fastify routes
/api/arena/chat (+ /v1/chat/completions) · /models & /v1/models · /control (WS) · /vote · /leaderboard · /analytics/* · /health
ArenaCore
parallel fan-out, one event stream, fault isolation, AbortSignal
Adapter layer
selectProtocol → SSE · AG-UI · A2UI · Vercel AI · OpenAI SSE (egress + ingress)
JoinBroker
pairs two sibling requests into one matchup (opt-in joinKey)
MatchupRegistry
AbortController + steer handler per matchup
SmartMatchmaker
under-sampled / high-variance pairs (RandomMatchmaker fallback)
MatchupTokenService
HMAC token, 15 min TTL, hash stored
PostgreSQL
conversations · turns · matchups · responses · preferences · model_ratings · model_style_ratings · model_rating_history
Model endpoints
Google · OpenAI · Ollama · vLLM · host proxy · mock
▲ writes model_ratings (+ append-only model_rating_history) + model_style_ratings (off the hot path)
worker (Python)
anomaly screen · Bradley-Terry + Rao-Kupper ties · Fisher-info CIs · connectivity · style-controlled pass · periodic refit

Distribution topology (single container)

Self-hosted as a single container per deployment: one Fastify process serves the API and the built web UI on one port (PORT, default 3001), same-origin. Static serving (via @fastify/static) activates only when web/dist exists (production/Docker), with an SPA fallback to index.html for any GET route outside the API prefixes /api, /health, /v1, /models, /chat/completions, /completions, /embeddings (WEB_DIST_DIR overrides the path). That exclusion list matters because the OpenAI-compatible surface lives at top-level paths: otherwise an unmatched GET /v1/models would answer HTML at 200 and an OpenAI client would report a mimetype error instead of a missing route. In npm dev the bundle is absent, so Vite serves :5173 and the API stays on :3001. docker compose up brings up Postgres + rating worker + app (entrypoint: migrate → seed → start). See Setup.

Ports (hexagonal boundaries)

PortImplementationFuture replacement
ModelProviderPortGoogle, OpenAI-compatible, Ollama, host proxy, mockadditional providers
ProviderResolverPortProviderRegistryunchanged
MatchmakingPortSmartMatchmaker (default) / RandomMatchmaker (fallback)King-of-the-Hill / bandit variants
MatchmakingStatsPortPostgresRepository (pair counts + CI widths)unchanged
PreferenceRepositoryPortPostgresRepositoryunchanged
LeaderboardPortwin-rate SQL + model_ratings + model_style_ratings LEFT JOINs, plus getRatingContext() (component connectivity + fitted style coefficients)more surfaced rating variants
AnalyticsPortsummary · head-to-head · model-metrics · activity · style-control · rating-history aggregations (percentiles/bucketing in TS for pg-mem testability)prompt-category scoping
EventAdapter (egress)native SSE (default) + AG-UI · A2UI · Vercel AI SDK · OpenAI SSE, via selectProtocolmore wire protocols
RequestAdapter (ingress)AG-UI, OpenAI, and Vercel AI SDK request envelopes; native SSE and A2UI have none and accept only OmniArena's own bodymore envelopes
Core ports are defined in server/src/core/ports.ts; the egress EventAdapter port in server/src/adapters/event-adapter.ts and the ingress RequestAdapter in request-adapter.ts. Tests inject in-memory implementations behind the same interfaces.

Stream orchestration

ArenaCore.stream() merges concurrent provider streams into one async event queue. A single plan omits slot B; a shadow plan runs both, but the chat route only forwards A-facing events while still persisting both slot_done payloads. If one slot errors, the other keeps streaming.

token slot_error slot_done matchup_done
Internal completion events capture TTFT, duration, provider/estimated tokens, Markdown density, and provider-reported model version. Public slot_done strips them; every matchup stores HARNESS_VERSION. The chat route hands each public event to the selected protocol adapter for framing.

Trigger and exposure

AxisEnvValuesDefault
Trigger (when)ARENA_TRIGGERalways | manual | sampledalways
Exposure (what)ARENA_EXPOSUREblind | shadowblind
PlanMeaning
matchupBlind A/B streamed, votable (blind + engaged)
shadowA streamed, B silent + logged, not votable (shadow + engaged)
singleOne model, nothing persisted (not engaged)
Defaults (always + blind) reproduce the historic every-request matchup path. Shadow requires ARENA_DEFAULT_MODEL (incumbent); the challenger is matchmaker-picked and ≠ incumbent. See Setup → Trigger and exposure.

Egress: protocol-adapter layer

One internal event stream (zod publicArenaEventSchema in core/events.ts) is fanned out to five wire protocols through a small egress port, so the chat route knows no framing details.

PublicArenaEvent stream
matchup_started · token · slot_error · slot_done · matchup_done · run_error (schema-validated)
▼ selectProtocol(?protocol → Accept → default)
native SSE
default · byte-for-byte unchanged
AG-UI
typed run/text events over SSE
A2UI
schema-validated NDJSON
Vercel AI SDK
UI message stream v1
OpenAI SSE
chat.completion.chunk
EventAdapter memberRole
headersResponse headers the protocol needs before the first chunk
serialize(event)Wire-ready bytes for one schema-validated event
finalize()Trailing bytes before close (e.g. a [DONE] sentinel)
inBandErrors?Set for a protocol whose clients settle a run on a terminal error event and read a non-2xx as a dead transport (AG-UI): the route then delivers a pre-stream failure as a run_error at 200 rather than an HTTP status
Unknown ?protocol= values fall back to native SSE, so the demo, the SDK, and existing clients are unaffected. Internal event semantics are identical across protocols; only the framing differs. See API → Protocol selection for aliases, media types, and framing.
Ingress too, for three protocols. A second port, RequestAdapter (request-adapter.ts), is implemented by the protocols with a canonical client request envelope — AG-UI's RunAgentInput, OpenAI's /chat/completions body, the AI SDK's useChat body. Two members: claims(body) (is this the protocol's envelope rather than OmniArena's?) and parse(body), which translates it into the one internal ArenaChatRequest the route runs on. Detection is structural — a messages array and no prompt — so OmniArena's own body is unchanged everywhere, and stock clients of those three need no transport of their own. selectProtocol() resolves both halves from one decision. See Integration → request bodies.
Slot failures on text-only protocols. AG-UI and OpenAI have no per-message error taxonomy, only assistant text, so a slot_error is carried twice there: structurally (AG-UI's CUSTOM slot_error, the OpenAI adapter's omni_arena_error extension) and as text prefixed with the shared [omni-arena:slot-error] marker (adapters/slot-error.ts), so a client that only renders content shows something instead of a permanently blank column — and the marker is what keeps that text distinguishable from the model having said those words.
Roster discovery is part of the surface. GET /models and GET /v1/models (routes/models.ts) return the enabled roster in OpenAI's {object:"list", data} shape — both paths, since a deployment may be configured with or without the /v1 prefix. An OpenAI client's first call is the model list (Open WebUI treats it as the connection check and builds its picker from it), so without it the OpenAI adapter is undiscoverable however well-formed its stream is. The roster is not secret; what stays blind is which two models a matchup drew.

WebSocket control plane

GET /api/arena/control (registered via @fastify/websocket in app.ts) acts on an in-flight matchup out-of-band from the token stream.

MatchupRegistry
AbortController + steer handler per matchup; register / bindSteer / release
ArenaCore.stream(…, signal?, attachSteer?)
stop closes the queue; steer aborts the generation and restarts both slots
MessageStatus
stopWorks: aborts the matchup's stream via its AbortController; ok:false for an unknown/finished matchup.
steerAbort-and-restart: cancels the current generation, emits steered, re-runs both slots with the identical system instruction; persists on matchups.steers. Negative ack for unknown/expired or already-completing matchups.

Slot join: one matchup across two requests

The default shape puts both slots on one connection. Real compare-view chat UIs don't: they fan a multi-model turn out into one request per model sharing a conversation id (Open WebUI v0.10 is the measured case). Each such request has exactly one answer channel, so interleaving both slots on it either garbles the two answers together or yields two unrelated matchups and two half-votes. A client opts in with joinKey; two requests resolving to the same scope inside a window become one matchup, one matchups row, one vote.

request 1 → leader (slot A)
reads conversation · matchmaker · token · inserts the matchup · owns the pump and all persistence
JoinBroker.claim(scope)
synchronous role assignment — no await between deriving the key and recording the claim, so two simultaneous siblings can never both win
request 2 → follower (slot B)
awaits the handshake, then streams slot B only; repeats none of the leader's work
▼ one ArenaCore generation → JoinedRound → one SlotChannel per slot
connection A
matchup_started slots:["A"] → tokens → slot_done → matchup_done
connection B
matchup_started slots:["B"] → tokens → slot_done → matchup_done
PieceRole
JoinScopeWhat a join is authorized by: HMAC of {sessionId, conversationId, prompt, joinKey} under a per-process random secret. The joinKey alone is only a correlation id the client already has, so it is deliberately not the capability; the tuple is strictly stronger than the session id that already gates conversation access, and the key never leaves the process.
JoinedRoundThe single shared generation, demultiplexed per slot. The leader keeps consuming even if its own client disconnects, so slot B still finishes and is recorded.
SlotChannelSingle-producer/single-consumer queue drained by one HTTP response. Aborting one connection ends only that connection; each side gets matchup_done as soon as its slot finishes. Backlog capped at ARENA_JOIN_MAX_QUEUED_EVENTS (default 4096), then the slot fails rather than growing without bound.
JoinHandshakeWhat the leader publishes to the follower: matchupId, matchupToken, conversationId, turnIndex.
FailureResponse
joinKey without a sessionId400 join_requires_session
Both slots of the scope already claimed409 join_slots_exhausted
Sibling arrives after the window closed409 join_expired
More than ARENA_JOIN_MAX_PENDING unpaired scopes503 join_unavailable
Leader never publishes (window + 30s)504 join_leader_timeout
Pre-stream failure on the leaderThe leader's own status/code, forwarded so both siblings report the same thing (500 join_failed when unclassified)
A window that closes with no sibling is not an error: the round degrades to exactly the default shape — both slots on the leader's connection, votable, nothing wasted. The leader's control-plane AbortController cascades onto the shared generation, so one stop stops both slots. ARENA_JOIN_WINDOW_MS=0 disables joining and a joinKey is then ignored — see Setup → environment variables.

Linear history

Turn 0
prompt → A/B → decisive vote
Winning response
server derives parent; client history ignored
Turn N
prior winners + next prompt → new A/B
Unique parent_response_id and (conversation_id, turn_index) prevent branching. Session IDs gate ownership. Ties, both-bad, skips, and unvoted turns end the branch.

Provider and key-custody modes

ModeBehavior
Direct custodyGoogle, OpenAI-compatible, or Ollama adapters call providers with OmniArena-held configuration; vLLM reuses OpenAI compatibility.
Host custodyHostProxyModelProvider calls the host's OpenAI-compatible proxy; upstream keys never enter OmniArena.
Mock (demos/e2e)MockModelProvider streams deterministic tokens with no network; registered only when ARENA_MOCK_PROVIDER=1 so it never shadows a real provider. See Setup.
PersistencePrompts and responses are stored as received; OmniArena does not transform or redact content.

Blind voting integrity

GuaranteeMechanism
No identity leak pre-votematchup_started carries only slot IDs + token
Unforgeable votesHMAC-SHA256 signed token with matchup + slot claims, 15 min expiry
Token never storedOnly its SHA-256 hash lands in the database
One vote per matchupUnique constraint on preferences.matchup_id
Holds for every protocolblindness.test.ts generates its cases from the protocol registry and asserts no identity reaches the wire on a single connection or on either sibling of a joined matchup — so a new adapter is covered without a new test

Rating worker

The worker/ package (omniarena_rating) computes the style-agnostic default leaderboard rating as a separate Python process — never on the Fastify request path. It follows a strict aggregate-then-compute design.

Its only input is recorded comparisons (preferences ⋈ matchups). A non-votable single round persists neither, so it never reaches the worker and a deployment mostly serving such rounds has nothing to rate. See Rating methodology → what the engine cannot rate.
StepModuleWhat it does
Anomaly screenanomaly.pyRuns before aggregation: p-value tests over anonymous sessions flag spam/malicious voters, excluded from the fit.
Aggregateaggregate.pyOne SQL GROUP BY → canonical (model_lo, model_hi, wins_lo, wins_hi, ties) triples. skip + flagged sessions excluded. O(votes) → O(model pairs); raw rows stay in Postgres.
Fitbradley_terry.pyLog-parameterized BT MLE via SciPy L-BFGS-B with analytic gradient. Rao-Kupper tie model (ordered logit, threshold ±η). Weak ridge prior, sum-to-zero anchoring, warm-start between loop refits — except every FULL_REFIT_EVERY refits (default 12), where the warm state is discarded for a from-scratch ground-truth fit that also cross-checks the warm path for drift.
Intervalsconfidence.pyInverse-Hessian (observed Fisher information) CIs projected through the anchoring contrast, validated by a multinomial bootstrap over the triples.
Connectivityconnectivity.pyUnion-find components; ratings only comparable within a component, isolated models get wide intervals.
Style controlstyle.pyHeavier periodic pass on raw votes: joint style-controlled BT regression (see below).
Write backwriteback.pyIdempotent upsert into model_ratings (default) and model_style_ratings + style_control_coefficients (style pass). The default pass also appends a snapshot per model into model_rating_history in the same transaction — the trail behind /analytics/rating-history.
Rating methodology: Rao-Kupper gives P(a≻b)=σ(d−η), P(b≻a)=σ(−d−η), P(tie)=σ(d+η)−σ(d−η) with d = r_a − r_b. The ridge-penalized log-likelihood is convex; the ridge (a Gaussian prior) identifies ratings, regularizes sparsely-compared models, and keeps the Hessian invertible for Fisher-information CIs. Reported on an Elo-like scale 1000 + (400/ln10)·r, centered per component. Full story in the rating methodology doc.
L-BFGS-B Rao-Kupper ties ridge prior sum-to-zero anchor Fisher-info CIs multinomial bootstrap warm-start refits periodic cold refit
Compose runs both passes. The worker service's image CMD is --loop --style, so a stock docker compose up keeps model_style_ratings and style_control_coefficients current alongside model_ratings, and the dashboard's style panels have data from the first refit onward. See Setup → rating worker.

Style-controlled ratings

Following LMSYS, style.py folds voter confounders into the same BT logistic regression as covariates: d = (r_a − r_b) + β·x, P(A≻B) = σ(d − η). Strengths and style coefficients are fit jointly with a ridge on all of them; because the per-vote deltas x vary, it runs on raw preferences ⋈ matchups ⋈ responses rows — a heavier pass (--style) kept off the fast path. Exposed as styleControlledRating.

position (left-slot) verbosity (tokens) formatting (markdown) latency (ttft) latency (duration)

Anomaly detection

anomaly.py screens anonymous sessions before any fit (Bonferroni-adjusted at α/3); a rejection excludes the session from both passes.

TestNull hypothesisCatches
VolumePoisson upper tail P(X≥n) vs mean votes/sessionvote-stuffing
Position biastwo-sided binomial on left/right vs p=0.5 (slots randomized)always-left/right bots
Speedmedian inter-vote gap below a floor (default 1.5s)automated clicking
On by default; --no-anomaly-filter disables it.

Smart matchmaking

SmartMatchmaker replaces uniform pair selection behind the same MatchmakingPort. It samples a pair proportional to an information score: coldness = 1/(1+games) (favor under-evaluated pairs) plus normalized rating-interval width (favor high-variance matchups; unrated models count as maximally uncertain). A small floor keeps every pair reachable. Default; MATCHMAKER=random restores the uniform matchmaker.

Frontend

The headless hooks live in the published @omni-arena/react SDK (packages/react-sdk/), and the demo imports them — a single source of truth. The package is deliberately layered: protocol, session, stream, and vote are framework-free modules and each hook is a thin React shell over them, so a non-React client can reuse the same wire logic instead of reimplementing it. Every hook accepts an optional baseUrl (default "" = same-origin/proxied).

ModuleExportsResponsibility
protocol.tsparseArenaMatchup · parseArenaReveal · parseArenaSlotError · isArenaSlot/isArenaVote/isDecisiveVoteNarrow untrusted wire JSON into typed events — the one place the wire shape is known
session.tsgetSessionIdAnonymous session id in localStorage, degrading to in-memory when storage is unavailable
stream.tsreadArenaStream · createArenaSseDecoderIncremental SSE decoding over a ReadableStream, independent of any hook
vote.tssubmitArenaVoteOne POST /api/arena/vote with the matchup token
useArenaChat.tsuseArenaChatPOSTs the prompt, drives readArenaStream into per-slot state, votes, and reveals identities only after a successful vote
useArenaVote.tsuseArenaVoteVoting on its own, for a UI that renders the stream itself (the integrations do)
useArenaLeaderboard.tsuseArenaLeaderboardStandings plus rating context
useArenaAnalytics.tsuseArenaSummary · useArenaHeadToHead · useArenaModelMetrics · useArenaActivity · useArenaStyleControl · useArenaRatingHistoryOne hook per analytics endpoint, all with the same { data, refresh, error } shape
The demo (web/) is a two-page SPA under react-router-dom (the server's SPA fallback makes deep links work in production). /ArenaPage: prompt box, two anonymous markdown panes, five vote buttons, reveal, and a leaderboard that shows the Elo-like rating ±CI half-width (falling back to win-rate %), plus style <rating> when a style-controlled rating exists. /insightsInsightsPage: a summary stat strip plus four chart tabs (web/src/dashboard/, primitives in web/src/charts/): Rankings (win-rate lollipop with 50% reference line, BT forest plot with CI whiskers, raw-vs-style-controlled dumbbell, rank-shift bump, vote-outcome stacked bars — all from the leaderboard payload), Head-to-head (pair win-rate matrix with games toggle and drill-down, tie-rate bars, connectivity callout by componentId), Style & bias (style-coefficient panel, slot-A vs slot-B position dumbbell, verbosity/latency/markdown vs win-rate scatters, latency p50–p90 spread), and Activity (rating-over-time lines, stacked vote volume, cumulative games). Ranked row charts are hand-rolled CSS grid/SVG; time series and scatters use recharts. Each tab is a nested route (/insights/rankings, /insights/head-to-head, /insights/style, /insights/activity; the index redirects to rankings) so a tab is a shareable deep link rather than component state, and each is lazily imported on its own — the arena never pays for the charting bundle and opening one tab does not load the other three.
Empty dashboards teach. Every chart derives from recorded votes, so a fresh deployment has nothing to plot and a card can only report what is missing. GettingStartedNotice states the remedy once at page level — the demo-data seeder when there are no votes, the worker command when votes exist but no fit does — and disappears once both are present. The page fetches the summary aggregate once and passes it to both the stat strip and the notice.
Reference integrations: two runnable example apps in examples/ — a Next.js + Vercel AI SDK app and an assistant-ui app — drive arena mode through the Vercel AI SDK adapter and are exercised by the deterministic e2e/ suite (npm run e2e, Playwright + mock provider), which also asserts the raw vercel-ai/ag-ui wire streams. See the integration guide.

Integrations layer

integrations/ is the layer above the examples: instead of apps written for the arena, it wires the arena into real upstream chat UIs at pinned revisions — which is what turns "the protocol should work" into evidence that it does.

DirectoryUpstreamProtocolShape
vercel-ai-chatbot/vercel/ai-chatbotvercel-aiPinned clone + overlay
assistant-ui/assistant-ui monorepo, examples/with-ag-uiag-uiPinned clone + overlay
open-webui/Open WebUI container imageopenaiPublished image + OpenAI-compatible bridge
Pinned clone + overlay. upstream.json pins the exact commit, .upstream/ is the gitignored clone, overlay/ holds the arena sources copied in verbatim, and scripts/overlay.mjs applies anchored patches to upstream's own files — each anchor must match exactly once, so an upstream that moved a line fails setup loudly instead of yielding a half-integrated app. The arena-specific code stays reviewable in overlay/ without diffing a vendored tree.
Open WebUI is the case that shaped the server. It sends one request per model for a compare turn — exactly what slot join exists to serve — and reaches the arena through a small OpenAI-compatible bridge (bridge/) rather than an overlay, because the UI ships as a container image. All three run key-free against the mock provider; see Setup.
Intentionally not built yet

Auto-judge for shadow matchupsARENA_EXPOSURE=shadow persists both responses with matchups.mode='shadow' and rejects human votes; feeding those rows into an LLM-as-judge → recordPreference path is a separate workstream (see Setup → Trigger and exposure). Multimodal input — deferred. OmniArena does not scrub or redact stored prompts/responses — content is persisted as received.