alpha · self-hosted · single user

The gateway that shows you what cheap actually costs.

Relay sits between your coding agent and OpenRouter. It routes each request to the cheapest model that can handle it, logs the real cost against a stated baseline, and measures the quality you traded away instead of asking you to assume there wasn't any.

Why this exists

Most of what a coding agent sends to a model is not hard. File summaries, one-line edits, tool-call plumbing, retries after a lint error — a large share of that traffic is answered just as well by a model costing an order of magnitude less than the one you have selected.

Almost nobody switches, and the reason is not laziness. Switching means guessing, and a guess about quality is not something you can defend. "It felt fine" is not a measurement. So the expensive model stays selected, and the bill is whatever it is.

Relay's position is that routing is the easy part. Ten lines of config will route by prompt length. The hard part is proving what that routing cost you — in dollars saved, and in answer quality given up. So the gateway is built around the measurement, and the router is the thing hanging off it.

That means Relay is willing to tell you it made things worse. A failover that escalated to a pricier model shows up as a negative number in the same total as the wins. A baseline whose price we don't know shows up as —, not $0.00. A quality claim that hasn't cleared its agreement gate doesn't get published at all.

What it is

   opencode              relay                    OpenRouter
   your scripts   ──▶    127.0.0.1:8000   ──▶    openrouter.ai
   openai SDK            /v1/chat/completions
                             │
                             ├─▶ route rules   → cheapest tier that fits
                             ├─▶ exact cache   → free repeat answers
                             ├─▶ failover      → sibling model on 5xx
                             ├─▶ spend cap     → 402 when the key is spent
                             └─▶ request_log   → tokens, cost, counterfactual
                                    │
                                    └─▶ dashboard · weekly digest · benchmark

An OpenAI-compatible endpoint. Anything that speaks /v1/chat/completions — the openai SDK, opencode, curl — points at Relay instead of at OpenRouter and keeps working, streaming and tool calls included. Everything runs on your machine; the only outbound calls are to OpenRouter.

  • [+] Tier routing — rules evaluated top to bottom, each request lands on cheap, smart, or baseline; the reason ships back in X-Relay-Route-Reason.
  • [+] Exact-match cache — in-memory by default, Redis if you point it at one. A hit is a full save with real token accounting.
  • [+] Failover — timeout, connect error, or 5xx retries once, then cycles to the sibling model inside the tier.
  • [+] Spend caps — per gateway key. Over the cap is a 402, not a surprise invoice.
  • [+] Full request log — model, tokens, latency, cost, route reason, trace id. One row per request, streaming included.
  • [+] Dashboard + weekly digest — server-rendered at /, one-line summary at /digest, optional webhook POST.
  • [+] Benchmark harness — replays real logged traffic through both the routed path and the baseline, scores them with an LLM judge, and refuses to publish a quality number the judge can't be trusted on.
  • [+] OpenTelemetry — spans on every dispatch, no-op when no collector is configured.
  • [-] SQLite by default — no Postgres, no Docker, no cloud. Tables auto-create on first start. Postgres is there if you want it.

How the savings number is built

  1. 1 · Route Each request is checked against your rules — message length, presence of tools, model alias — and dispatched to the first tier that matches. Nothing is inferred or learned; the decision is deterministic and logged with the rule that produced it.
  2. 2 · Price both sides Relay records what the request actually cost, and what it would have cost had your stated baseline model served it instead. That second number is the counterfactual, and it is the only thing that makes "saved" mean anything.
  3. 3 · Subtract savings = counterfactual − actual. Positive means the routing paid. Negative means a failover or an escalation cost you more than doing nothing would have — it stays in the total. Unknown baseline price means NULL, rendered as —.
  4. 4 · Calibrate The naive version assumes the baseline would have written the same number of output tokens. That's an estimate, and it's labelled as one. Run the benchmark and Relay learns real per-category output-length ratios from measured traffic, at which point the number is labelled a measurement instead.
  5. 5 · Gate the quality claim A dual-path benchmark run answers every sampled case twice — routed and baseline — and an LLM judge scores the pairs blind, with path identity hidden and side order randomised. The judge is then checked against your own hand-labels with Cohen's κ. Below the threshold, the run is blocked and the dashboard shows no quality delta at all.

Honest accounting

Four outcomes, four different renderings. None of them round toward the flattering answer.

  • [+] Positive — routing beat the baseline. Counted.
  • [-] Negative — a failover or escalation cost more than the baseline would have. Shown in red, counted in the same aggregate. It is not quietly floored at zero.
  • [+] Zero-cost hit — the cache answered it. Full save, with tokens read from the cached body so the accounting is real.
  • [x] Unknown — the baseline model isn't in the price cache, so the counterfactual can't be computed. Written as NULL, displayed as —. Writing $0.00 here would be a lie about a number we don't have.
Errors don't earn savings. A stream that fails mid-flight writes NULL, not a partial win. And the routed-cheap percentage filters its numerator and denominator identically, so it cannot exceed 100%.

Install

Straight from the repo — Relay isn't on PyPI and doesn't need to be for a self-hosted sidecar. Python 3.12+.

uv tool install git+https://github.com/pulkyeet/relay

Then, three commands:

# 1. Write ~/.relay/relay.yaml, generate a gateway key, create the SQLite DB
relay init

# 2. Paste your OpenRouter key into ~/.relay/relay.yaml under openrouter.api_key
#    (or export OPENROUTER_API_KEY instead)

# 3. Start it
relay run

# → http://127.0.0.1:8000/health  {"status":"ok","version":"0.1.0"}
# → http://127.0.0.1:8000/         dashboard
relay init prints the opencode provider snippet below, pre-filled with the gateway key it just generated. Binding is 127.0.0.1 by default — --host and --port are there if you need them, but the dashboard's only auth is a shared token, so think before you widen it.

Wiring it into opencode

Relay registers as a custom OpenAI-compatible provider. Add this to ~/.config/opencode/opencode.jsonc:

{
  "provider": {
    "relay": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Relay Gateway",
      "options": {
        "baseURL": "http://127.0.0.1:8000/v1"
      },
      "models": {
        "cheap":    { "name": "Cheap (alias → ling-2.6-flash)" },
        "smart":    { "name": "Smart (alias → deepseek-v4-pro)" },
        "baseline": { "name": "Baseline (alias → deepseek-v4-flash)" }
      }
    }
  }
}
  1. 1 Run /connect inside opencode.
  2. 2 Search for relay — it's the provider you just added.
  3. 3 Paste the gateway key from ~/.relay/relay.yaml.
  4. 4 /models now lists Cheap, Smart, and Baseline. Pick one and work normally.
Point opencode at the aliases, not at concrete model ids — that's what lets you reshuffle tiers in relay.yaml without touching opencode config. Traffic through this provider bills your OpenRouter key; your existing opencode subscription is a separate path you can switch back to any time.

Or point any OpenAI client at it:

from openai import OpenAI

client = OpenAI(
    base_url="http://127.0.0.1:8000/v1",
    api_key="sk-relay-...",          # your gateway key, not the OpenRouter one
)

client.chat.completions.create(
    model="cheap",                   # alias → resolved by the router
    messages=[{"role": "user", "content": "..."}],
)

Configuring the router

Everything lives in ~/.relay/relay.yaml. Tiers name the models; routes decide which tier a request lands on, evaluated top to bottom, first match wins.

tiers:
  cheap:
    models: ["inclusionai/ling-2.6-flash", "nex-agi/nex-n2-mini"]   # primary, sibling
  smart:
    models: ["deepseek/deepseek-v4-pro", "xiaomi/mimo-v2.5-pro"]
  baseline:
    models: ["deepseek/deepseek-v4-flash", "xiaomi/mimo-v2.5"]

routes:
  - name: "long-content"
    conditions: [{ type: message_length, min: 4000 }]
    route_to: "smart"
  - name: "tool-use"
    conditions: [{ type: has_tools, value: true }]
    route_to: "smart"
  - name: "default"
    route_to: "cheap"

savings:
  baseline_model: "deepseek/deepseek-v4-flash"   # the counterfactual reference
  methodology: naive                              # "calibrated" after a benchmark run
Config is validated at load. An alias or route pointing at a tier that doesn't exist fails the startup, not the first request.
KeyDefaultWhat it does
gateway_keys[]{name, key, spend_cap_usd} — the keys your clients authenticate with
openrouter.api_key""Or set OPENROUTER_API_KEY
database.urlSQLite in ~/.relay/postgresql+asyncpg://… for Postgres
modenormalpassthrough to dogfood — forces payload + stream capture, skips routing
savings.methodologynaivecalibrated once you have a judged benchmark run
savings.digest_webhook_url""POSTs the weekly one-liner to Slack/Discord
benchmark.judge_modelopenai/gpt-4oModel used to score routed vs baseline answers
benchmark.kappa_threshold0.7Minimum judge/human agreement before a quality claim publishes
dashboard.admin_token""Bearer token on / and /digest; empty means open
log_payloadsfalseStore request/response bodies — required for benchmark sampling

Proving it

The benchmark is what turns the savings figure from an estimate into a measurement. It replays your own logged traffic — not a synthetic eval set — through both paths and scores the pairs.

# 1. Dogfood for a few days with log_payloads: true
#    (mode: passthrough if you want unrouted reference traffic)

relay-benchmark sample --seed 0        # stratified draw from request_log
relay-benchmark run --dry-run          # preview the cost first — this spends real money
relay-benchmark run --concurrency 4    # answer every case twice: routed + baseline

relay-benchmark annotate --run-id 1    # hand-label a slice yourself
relay-benchmark judge --run-id 1       # LLM judge, blind; computes Cohen's κ vs your labels
relay-benchmark ratios --run-id 1      # per-category output-length calibration

# then flip methodology: calibrated + calibration_run_id in relay.yaml

The judge never sees which answer came from which path — the labels are answer_A and answer_B, and which path lands on which letter is randomised per call. If its agreement with your hand-labels comes in under the κ threshold, the run is marked blocked and no quality delta is published anywhere. Lowering the threshold to make a number appear is the one thing this design exists to stop you doing.

CommandWhat it does
relay initStarter config, gateway key, SQLite DB, opencode snippet
relay runStart the gateway. --host, --port, --config
relay migrateAlembic migrations (Postgres; SQLite auto-creates)
relay-benchmark sampleStratified draw of recent traffic into benchmark cases
relay-benchmark runDual-path execution. Costs money. --dry-run, --resume
relay-benchmark annotateInteractive hand-labelling → ~/.relay/judge_handlabels.yaml
relay-benchmark judgeBlind LLM scoring + Cohen's κ gate
relay-benchmark ratiosDerive calibration ratios from a judged run
relay-benchmark statusLatest run's quality delta, κ, and methodology

Shipped & upcoming

Shipped

  • [+] Transparent proxy: streaming, tool calls, error passthrough
  • [+] Rules router, tiers, aliases, within-tier cycling
  • [+] Retry, failover, timeouts, spend caps
  • [+] Exact-match cache (memory or Redis)
  • [+] Savings engine, naive and calibrated
  • [+] Dashboard, weekly digest, digest webhook
  • [+] Benchmark: sample, dual-path run, blind judge, κ gate, ratios
  • [+] SQLite sidecar, OTel spans, relay init/run/migrate

Upcoming

  • [ ] Learned router — a classifier trained on judge labels, closing the loop from traffic to labels to a better router. Designed; waiting on a clean judged run to train against.
  • [ ] Shadow sampling — continuously replay a slice of live traffic against the baseline, so the quality delta refreshes itself instead of waiting for a manual benchmark. Most of the machinery already exists in the runner.
  • [ ] Semantic cache — a higher hit rate than exact match, once the hot-path cost and the wrong-answer risk are worth it.

FAQ

When do the published numbers land?

Savings and quality delta publish together or not at all — a cost figure without the quality it bought is half an answer, and half an answer is what this project exists to replace. Both go up the moment a benchmark run clears the κ ≥ 0.7 agreement gate against a blinded label set.

Your own numbers don't wait on that. Point Relay at your traffic and the dashboard shows your savings, your routing mix, and your per-model breakdown from the first request onward.

Does it work with anything besides opencode?

Yes — it's a plain OpenAI-compatible endpoint. The openai SDK, LangChain, curl, your own scripts, anything that takes a base_url. Streaming and tool calls are covered by contract tests against the real SDK.

What does it cost to run the benchmark?

Real money on OpenRouter — roughly $5–30 for a couple hundred cases answered twice, plus $2–5 for the judge calls. --dry-run shows you the shape of the run before you spend anything, and --resume picks up where an interrupted run stopped.

Is my traffic going anywhere?

To OpenRouter, and to the digest webhook if you configure one. Nowhere else. Everything — config, database, dashboard — is local. There's no telemetry back to anyone, and the OTel exporter is a no-op unless you point it at your own collector.

Postgres or SQLite?

SQLite by default, and it's the intended path for a single-user install — tables auto-create on startup, nothing to provision. Postgres works via the postgres extra; the models use cross-dialect JSONB and UUID adapters so the same schema runs on both.

How safe is the dashboard?

It's a shared secret, not a login. dashboard.admin_token gates / and /digest via a Bearer header or a ?token= query param, and it's empty by default because the server binds to localhost. If you expose the port, set the token.