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.
$ relay run relay 0.1.0 · http://127.0.0.1:8000 · sqlite ~/.relay/relay.db POST /v1/chat/completions route cheap rule "default" model inclusionai/ling-2.6-flash tokens 1,204 in / 388 out cost $0.000191 baseline $0.000642 saved $0.000451 POST /v1/chat/completions route smart rule "tool-use" model deepseek/deepseek-v4-pro saved -$0.001103 escalated above baseline
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, orbaseline; the reason ships back inX-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 · 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 · 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 · 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 meansNULL, rendered as—. - 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 · 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.00here would be a lie about a number we don't have.
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
pipx install git+https://github.com/pulkyeet/relay
git clone https://github.com/pulkyeet/relay && cd relay uv sync
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 Run
/connectinside opencode. - 2 Search for
relay— it's the provider you just added. - 3 Paste the gateway key from
~/.relay/relay.yaml. - 4
/modelsnow lists Cheap, Smart, and Baseline. Pick one and work normally.
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
| Key | Default | What it does |
|---|---|---|
gateway_keys | [] | {name, key, spend_cap_usd} — the keys your clients authenticate with |
openrouter.api_key | "" | Or set OPENROUTER_API_KEY |
database.url | SQLite in ~/.relay/ | postgresql+asyncpg://… for Postgres |
mode | normal | passthrough to dogfood — forces payload + stream capture, skips routing |
savings.methodology | naive | calibrated once you have a judged benchmark run |
savings.digest_webhook_url | "" | POSTs the weekly one-liner to Slack/Discord |
benchmark.judge_model | openai/gpt-4o | Model used to score routed vs baseline answers |
benchmark.kappa_threshold | 0.7 | Minimum judge/human agreement before a quality claim publishes |
dashboard.admin_token | "" | Bearer token on / and /digest; empty means open |
log_payloads | false | Store 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.
| Command | What it does |
|---|---|
relay init | Starter config, gateway key, SQLite DB, opencode snippet |
relay run | Start the gateway. --host, --port, --config |
relay migrate | Alembic migrations (Postgres; SQLite auto-creates) |
relay-benchmark sample | Stratified draw of recent traffic into benchmark cases |
relay-benchmark run | Dual-path execution. Costs money. --dry-run, --resume |
relay-benchmark annotate | Interactive hand-labelling → ~/.relay/judge_handlabels.yaml |
relay-benchmark judge | Blind LLM scoring + Cohen's κ gate |
relay-benchmark ratios | Derive calibration ratios from a judged run |
relay-benchmark status | Latest 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.