Results
Each policy replays the same 300 prompts (bench/prompts.synthetic.jsonl, test split) through the gateway's HTTP app. Thresholds were tuned on the separate dev split. Default config: config/default.json (thresholds 0.25/0.5).
| Policy | Quality proxy | Cost / 1k req | p50 | p95 | Tier mix (cheap / std / premium) | Retries | Cache hits (false) |
|---|---|---|---|---|---|---|---|
| always-cheap | 62.0% | $0.05 | 506 ms | 1044 ms | 100% / 0% / 0% | 0 | - |
| always-standard | 80.0% | $0.29 | 955 ms | 2042 ms | 0% / 100% / 0% | 4 | - |
| always-premium | 98.3% | $1.26 | 1910 ms | 4099 ms | 0% / 0% / 100% | 7 | - |
| routed | 86.0% | $0.44 | 684 ms | 3472 ms | 60% / 15% / 25% | 5 | - |
| routed + exact cache | 86.0% | $0.34 | 434 ms | 3351 ms | 59% / 16% / 26% | 3 | 84 (0) |
| routed + semantic cache | 75.7% | $0.25 | 315 ms | 3078 ms | 61% / 16% / 23% | 2 | 129 (35) |
- Against premium: routing cut cost 65% and p50 latency from 1910 ms to 684 ms, for 12.3 points of quality proxy.
- Against a fixed standard tier: 6.0 points better for $0.15 more per 1k requests. Lower thresholds trace the frontier upward: routed 0.1/0.3 reaches 92.7% at $0.56.
- Exact cache: 84 hits, 0 wrong, 22% cheaper than routing alone. Semantic cache: 129 hits, of which 35 answered a different question; quality fell to 75.7%. It is off by default.
- Blind spot: on short puzzle-style prompts with no complexity cues (
tricky), the router scores 8.0% against 100.0% for premium. That category explains most of the gap.
Quality proxy by prompt category
Rows highlighted where routing loses 30 points or more against premium.
| Category | always-cheap | always-standard | always-premium | routed |
|---|---|---|---|---|
| classify | 100.0% | 100.0% | 100.0% | 100.0% |
| code | 46.7% | 66.7% | 100.0% | 66.7% |
| debug | 26.9% | 46.2% | 100.0% | 100.0% |
| design | 3.8% | 26.9% | 84.6% | 84.6% |
| extract | 90.9% | 95.5% | 100.0% | 90.9% |
| long-easy | 100.0% | 100.0% | 100.0% | 100.0% |
| math | 21.7% | 73.9% | 95.7% | 95.7% |
| qa | 100.0% | 100.0% | 100.0% | 100.0% |
| rewrite | 92.0% | 100.0% | 100.0% | 92.0% |
| sql | 41.7% | 83.3% | 100.0% | 83.3% |
| summarize | 71.9% | 96.9% | 100.0% | 90.6% |
| tricky | 4.0% | 52.0% | 100.0% | 8.0% |
Threshold sweep
Routed policy, no cache, complexity thresholds (cheap | standard, standard | premium). These are the light blue points in the chart.
| Policy | Quality proxy | Cost / 1k req | p50 | p95 | Tier mix (cheap / std / premium) | Retries | Cache hits (false) |
|---|---|---|---|---|---|---|---|
| routed 0.1/0.3 | 92.7% | $0.56 | 1003 ms | 3623 ms | 38% / 28% / 34% | 4 | - |
| routed 0.2/0.4 | 88.3% | $0.49 | 721 ms | 3472 ms | 55% / 15% / 30% | 5 | - |
| routed 0.25/0.5 | 86.0% | $0.44 | 684 ms | 3472 ms | 60% / 15% / 25% | 5 | - |
| routed 0.3/0.55 | 82.0% | $0.34 | 632 ms | 3351 ms | 66% / 17% / 17% | 4 | - |
| routed 0.4/0.65 | 80.3% | $0.33 | 597 ms | 3351 ms | 70% / 13% / 17% | 3 | - |
| routed 0.5/0.75 | 72.7% | $0.18 | 550 ms | 2196 ms | 75% / 21% / 4% | 2 | - |
| routed 0.6/0.9 | 65.7% | $0.10 | 528 ms | 1653 ms | 83% / 17% / 0% | 1 | - |
Quickstart
Requires Node 20 or later. No API keys: the bundled config uses simulated models.
git clone https://github.com/seanmcrae/llm-gateway-cost-aware-LLM-router-.git llm-gateway && cd llm-gateway
npm ci && npm run build && npm start # http://127.0.0.1:8787/v1
curl -s http://127.0.0.1:8787/v1/chat/completions \
-H 'authorization: Bearer sk-local-acme-demo' -H 'content-type: application/json' \
-d '{"model":"auto","temperature":0,"messages":[{"role":"user","content":"Translate to French: good morning"}]}' -i
Point any OpenAI SDK at http://127.0.0.1:8787/v1. Use model: "auto" to route, a tier name (cheap, standard, premium) to fix a tier, or a model id to pin one. npm run demo prints the walkthrough below; npm run bench reruns the benchmark.
Demo output
Real output of npm run demo, regenerated when this page was built.
Routing by complexity (tenant acme, model=auto)
> Easy: sentiment classification
200 mock-small (cheap) cost $0.000021 latency 319 ms attempts 1 cache miss
route: complexity 0.00 -> cheap (simple:classify,sentiment)
> Medium: small coding task
200 mock-medium (standard) cost $0.000155 latency 935 ms attempts 1 cache miss
route: complexity 0.40 -> standard (code)
> Hard: multi-constraint design question
200 mock-large (premium) cost $0.002067 latency 2834 ms attempts 1 cache miss
route: complexity 0.67 -> premium (reasoning:design,migration,analyze; constraints:4; questions:2)
> Same easy request again (temperature 0)
200 mock-small (cheap) cost $0.000000 latency 0 ms attempts 0 cache exact
route: exact cache hit (similarity 1.000)
Per-request controls
> Hard question with x-llm-gateway-max-cost-usd: 0.004
200 mock-medium (standard) cost $0.000458 latency 1853 ms attempts 1 cache bypass
route: complexity 0.67 -> premium (reasoning:design,migration,analyze; constraints:4; questions:2); max cost $0.004 excluded mock-large
> Hard question for tenant globex (latency SLO 5000 ms)
200 mock-medium (standard) cost $0.000458 latency 1452 ms attempts 1 cache miss
route: complexity 0.67 -> premium (reasoning:design,migration,analyze; constraints:4; questions:2); latency SLO 5000 ms: mock-medium before mock-large
> Pinned model, bypassing the router
200 mock-large-alt (premium) cost $0.000510 latency 1035 ms attempts 1 cache bypass
route: pinned to mock-large-alt
Failure handling: mock-large forced to fail every call
> Hard question #1
200 mock-large-alt (premium) cost $0.002211 latency 5234 ms attempts 4 cache miss
route: complexity 0.67 -> premium (reasoning:design,migration,analyze; constraints:4; questions:2)
> Hard question #2
200 mock-large-alt (premium) cost $0.001476 latency 3946 ms attempts 4 cache miss
route: complexity 0.67 -> premium (reasoning:design,migration,analyze; constraints:4; questions:2)
> Hard question #3
200 mock-large-alt (premium) cost $0.001086 latency 1551 ms attempts 2 cache miss
route: complexity 0.67 -> premium (reasoning:design,migration,analyze; constraints:4; questions:2)
GET /v1/usage (acme)
{"tenant":"acme","month":"2026-10","budgetUsd":50,"spentUsd":0.00321125,"reservedUsd":0,"remainingUsd":49.99678875,"spentFraction":0.000064}
GET /metrics (excerpt, failing gateway)
llm_gateway_upstream_attempts_total{model="mock-large",outcome="error"} 5
llm_gateway_upstream_attempts_total{model="mock-large-alt",outcome="ok"} 3
llm_gateway_upstream_attempts_total{model="mock-large",outcome="breaker_open"} 2
llm_gateway_breaker_state{model="mock-large"} 2
llm_gateway_breaker_state{model="mock-large-alt"} 0
Architecture
Design decisions
- Heuristic scoring, not a model call, on the hot path. Scoring runs on every request, so it is a handful of regexes and counts with every contributing signal reported back to the client. A learned router is the obvious next step, and the benchmark is built to evaluate one.
- Asymmetric errors. Routing a hard prompt cheap costs quality; routing an easy one premium only costs money. Weights lean towards over-routing, and the fallback chain goes up a tier before it goes down.
- Hard constraints filter, soft ones reorder. Context window, cost cap and remaining budget remove candidates; the latency SLO only reorders them, because a slow answer is better than none.
- Reserve, then settle. A request reserves its worst-case cost across the fallback chain before calling upstream and settles to provider-reported usage afterwards, so concurrent requests cannot overspend a budget together. Rate-limit token estimates are corrected the same way.
- One breaker per model, outcomes from the gateway's view. Upstream 400s count as healthy (the request was bad, not the provider); auth failures skip to the next model without retries.
- Cache only what is meant to be deterministic. Temperature-0 requests by default, never across tenants, and the semantic layer requires the earlier conversation to match exactly.
- Buffered streaming.
stream: truereturns a correctly framed SSE stream after the full completion exists, so retries and fallbacks stay invisible. It keeps clients working but does not improve time to first token. - Everything time-dependent is injected. That is what lets the benchmark replay about 3,900 requests through the real HTTP stack in about two seconds and get identical results on every machine.
How evaluation works
The gateway only sees prompts. After each response, the harness reads which model answered and
scores the answer with a quality proxy: a model of simulated capability c answers a prompt of
labelled difficulty d acceptably with probability sigmoid(2(c - d) + 1), with the random draw
fixed per prompt so a stronger model never fails a prompt a weaker one passes. A cache hit counts
only if the reused answer was acceptable and belonged to the same question. Cost is the sum of
per-request costs from provider-reported tokens and configured prices. Latency is gateway-measured
per request, including retries and backoff, on a virtual clock; requests are sequential, so it is
service time rather than latency under load. Details in bench/README.md.
The proxy measures routing decisions under a stated capability model. It says nothing about any
real model's quality, and the absolute numbers depend on the illustrative capabilities and prices
in config/default.json. The useful outputs are the relative positions of the policies and the
per-category breakdown (qualityByCategory in bench/results.json).
Product brief
Rendered from docs/PRODUCT.md.
Problem
Teams that ship LLM features usually pick one model per feature and send it every request. The choice is made once, for the hardest case the team can think of, so a premium model ends up classifying sentiment and extracting order numbers at an order of magnitude more per token than a model that would do those jobs as well. Three other problems arrive with that setup:
- Cost is invisible per request. Provider invoices are per account, not per feature or customer, so nobody can say which tenant or endpoint is spending the money until the bill arrives.
- One provider is a single point of failure. 429s, 529 "overloaded" responses and timeouts are routine. Without retries, backoff and a second provider, each one becomes a user-facing error.
- Budgets are enforced after the fact. A runaway loop or a heavy customer can spend a month's budget in an afternoon, because nothing checks spend before calling the model.
A gateway in front of the providers can fix all four in one place, but only if switching to it costs a client nothing (same API) and its routing decisions are explainable enough to trust.
Users and jobs to be done
| User | Job | What they need from the gateway |
|---|---|---|
| Application engineer adding an LLM feature | "Call a model without deciding which one, and without writing retry code." | Drop-in OpenAI-compatible endpoint, model: "auto", sane failure behaviour |
| Platform or infra engineer | "Give every team LLM access with limits, and keep it up when a provider is down." | Per-tenant keys, rate limits, budgets, breakers, fallbacks, Prometheus metrics |
| Engineering or product lead who owns the AI budget | "Know what each feature costs and cut it without a quality regression I can't see." | Per-request cost telemetry, a benchmark that shows the quality/cost trade-off before a policy change ships |
| Finance or ops partner | "No surprise invoices." | Hard monthly budgets with reservations, downgrade before the limit, usage endpoint |
Scope
In (v0.1):
POST /v1/chat/completionscompatible with OpenAI clients, including buffered streaming.- Routing policies: complexity-routed, fixed tier, pinned model; per-request cost cap and latency SLO; budget-aware downgrade.
- Fallback chain, retries with jittered backoff, per-attempt timeouts, per-model circuit breakers.
- Exact and semantic response caching for deterministic requests, scoped per tenant.
- Per-tenant API keys, requests-per-minute and tokens-per-minute limits, monthly budgets.
- JSONL request events and Prometheus metrics.
- OpenAI-compatible and Anthropic adapters; deterministic mock provider as the default.
- Offline replay benchmark with a synthetic prompt set, and a static docs site.
Out (for now):
- Tool calling, function calling,
n > 1, images, audio and the embeddings endpoint. - Token-by-token streaming passthrough (streaming is buffered).
- Shared state across instances (Redis or a database), an admin UI, SSO.
- Prompt or response content filtering, PII redaction.
- Billing and invoicing; the gateway reports cost, it does not charge for it.
Requirements
Functional:
- An unmodified OpenAI SDK pointed at the gateway's base URL works for chat completions.
- Every response states the model, tier, policy, cache result, cost and route reason in headers, and every request (success or failure) produces exactly one telemetry event.
- A request never starts if its worst-case cost exceeds the tenant's remaining budget.
- Transient upstream errors (429, 5xx, 529, timeouts, network) are retried with backoff and then fall back to another model; client errors (400) are not retried; auth errors skip the model.
- A model whose breaker is open is skipped without spending an attempt.
- Cached answers are never shared across tenants and are only used for temperature-0 requests unless the client opts in.
- Invalid configs fail at startup with every problem listed.
Non-functional:
- Routing overhead is regexes and counts, no extra model call on the hot path.
- Runs with no API keys and no network by default; tests and CI never touch the network.
- Deterministic under test: time, sleep and randomness are injected.
Success metrics and evals
The gateway measures itself with a replay benchmark (npm run bench, method in
bench/README.md) on the bundled synthetic prompt set. Thresholds are tuned
on the dev split and reported on the test split. Current numbers (test split, 300 prompts,
simulated providers):
| Metric | Target for a policy change | Current (routed vs always-premium) |
|---|---|---|
| Cost per 1k requests | Lower, with the change explained | $0.44 vs $1.26 (65% lower) |
| Quality proxy | No more than the agreed drop vs the premium baseline | 86.0% vs 98.3% |
| p50 / p95 latency | No p95 regression beyond the tenant's SLO | 684 / 3472 ms vs 1910 / 4099 ms |
| False cache hits | Zero for any cache mode that is on by default | Exact: 0 of 84 hits. Semantic: 35 of 129 (off by default) |
| Quality per category | No category collapses silently | tricky prompts: 8% routed vs 100% premium |
The last row is why the benchmark reports per-category quality: the routed policy's aggregate
looks acceptable while one category fails almost completely. In a real deployment the same
metrics would come from the telemetry log (costUsd, premiumCostUsd for savings, latencyMs,
fallbacks, retries, cache hit rate) plus a sampled offline grader for quality.
Operational metrics on /metrics: request count by status and cache result, spend by tenant and
model, upstream attempts by outcome, latency histogram, breaker state per model.
Minimum viable quality
Thresholds for shipping a routing policy or config change, read off the replay benchmark's test split. They are stated against the fixed-tier baselines in the same run, so they still mean something when prices or capabilities change. All quality figures are the simulated proxy.
| Metric | Do not ship | Ship | Delight | Current default (routed 0.25/0.5, exact cache) |
|---|---|---|---|---|
| Quality proxy | Below always-standard (80.0%): a fixed mid tier would beat routing | At least 85% | At least 92% | 86.0%: ship |
| Cost per 1k vs always-premium ($1.26) | More than 50% of it | At most 50% | At most 30% | $0.34, 27%: delight |
| p95 latency | Above always-premium (4,099 ms) | At most always-premium | At most always-standard (2,042 ms) | 3,351 ms: ship |
| False cache hits, default-on cache mode | Any | Zero | Zero | 0 of 84: ship |
| Worst category | Below always-cheap in that category (routing made it worse than the cheapest model) | At or above always-cheap | Every category at 50% or more | tricky 8% vs 4% always-cheap: ship, not delight |
The default clears the ship bar and misses delight on quality and on the worst category, both for
the same reason (tricky prompts, issue #6). In the threshold sweep, 0.1/0.3 reaches delight on
quality (92.7%) and on the category floor (lowest is tricky at 52%) while staying at ship on cost
(44% of premium). That was observed on the test split, so it cannot be adopted without re-tuning on
dev first.
Cost at 1x and 10x usage
Estimates only: measured cost per 1k requests from bench/results.json multiplied by a volume.
The per-token prices in config/default.json are illustrative tier shapes (cheap $0.15 / $0.60,
standard $0.80 / $3.20, premium $3 / $15 per million input / output tokens), not vendor quotes, and
nothing here comes from a real deployment. 1x is a round 1M requests per month, chosen for
illustration; the prompt mix is assumed to match the synthetic test split.
| Policy | $ per 1k requests | 1x: 1M requests / month | 10x: 10M requests / month |
|---|---|---|---|
| always-premium (baseline) | 1.259 | about $1,260 | about $12,600 |
| always-standard | 0.290 | about $290 | about $2,900 |
| routed, no cache | 0.442 | about $440 | about $4,420 |
| routed + exact cache (default) | 0.345 | about $345 | about $3,450 |
Against always-premium, the default saves about $910 a month at 1x and about $9,100 at 10x. For
scale, the demo tenant's $50 monthly budget in config/default.json covers about 145k requests at
the default's cost per 1k.
What does not scale linearly:
- Cache savings depend on repetition, not volume. The test split repeats about a third of its questions; real traffic with fewer exact repeats gets closer to the no-cache line.
- State is per process. At 10x a single instance is unlikely to be enough, and budgets, rate limits and breakers then need a shared store (Redis on the roadmap). That infrastructure, and the gateway's own compute, are not in these figures.
- Routing quality, not price, is the larger risk at volume. At 10x the 25
trickyprompts in 300 become roughly 830k cheap-tier answers a month at 8% proxy quality, if the mix holds.
Trade-offs and alternatives considered
- Heuristic router vs learned router vs LLM-as-router. A classifier trained on graded traffic would beat regexes, and asking a small model to rate difficulty would catch the "short but hard" prompts the heuristic misses. Both need labelled data or add a model call to every request. The heuristic ships first because it is free, explainable in a response header, and gives the benchmark a baseline any learned router has to beat.
- Cascade (try cheap, escalate on low confidence) vs route up front. A cascade can be more accurate but pays for two calls and two latencies on every escalation, and needs a confidence signal the providers do not expose consistently. Route-up-front plus fallback keeps one call per request in the common case.
- Semantic cache on vs off by default. It served about 50% more hits than exact matching on the benchmark, and on the same run 27% of its hits answered a different question. For templated prompts that differ in one detail, embedding similarity is the wrong signal. Exact caching is the default; semantic caching is opt-in per deployment.
- Buffered vs passthrough streaming. Passthrough improves time to first token but makes fallback after the first byte impossible to hide. v0.1 buffers, so failures stay invisible.
- In-memory vs shared state. In-memory state keeps the gateway a single binary with no dependencies; it means budgets and rate limits are per instance. Acceptable for one instance or a sidecar, not for a horizontally scaled deployment.
- Build vs adopt. Hosted routers and open-source proxies exist. Building this one is about owning the policy layer (budget reservations, SLO-aware ordering, explainable routing) and a benchmark that makes policy changes reviewable, not about the proxying.
Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Router sends hard prompts to the cheap tier and quality drops unnoticed | High (measured: tricky category) |
High | Per-category quality in the benchmark; pin or fix the tier for sensitive endpoints; learned router on the roadmap |
| Benchmark does not reflect real traffic | High | Medium | Replay real (consented, scrubbed) traffic with a real grader before trusting absolute numbers |
| Semantic cache returns a wrong answer | Measured on the synthetic set | High | Off by default; exact cache only for temperature 0; threshold and false-hit rate reported |
| Budget overshoot across instances | Medium when scaled out | Medium | Single instance per budget today; shared ledger on the roadmap |
| Provider price changes make cost numbers wrong | Medium | Low | Prices are config, not code; telemetry records the price used at the time |
| Gateway becomes a new single point of failure | Medium | High | Stateless apart from caches and counters; run more than one behind a load balancer once state is shared |
Roadmap
Now (v0.1, this repository): OpenAI-compatible endpoint, heuristic routing with budget, cost cap and SLO constraints, fallback chain with retries and breakers, exact and semantic caching, per-tenant limits and budgets, JSONL and Prometheus telemetry, OpenAI-compatible and Anthropic adapters, synthetic replay benchmark and docs site.
Next:
- Replay mode against real providers with an LLM or rubric grader, so the quality proxy can be replaced by measured quality on a team's own prompts.
- Learned router (logistic regression on the existing features plus embedding features), compared against the heuristic on the same replay.
- Shared state in Redis for budgets, rate limits and breakers.
- Token-by-token streaming passthrough for the primary attempt, with buffered fallback.
- Per-tenant routing overrides by endpoint or prompt tag.
Later:
- Cascade policy (cheap first, escalate on a self-check) as a third policy in the benchmark.
- Tool calling passthrough.
- Cost anomaly alerts from the telemetry stream.
- Admin UI for tenants, keys and budgets.
Limitations
- State (budgets, rate limits, breakers, cache, latency windows) is in memory and per process. A multi-instance deployment needs a shared store such as Redis; the telemetry log is the only durable record.
- The quality numbers are a simulation over synthetic prompts. Real traffic, real models and a real grader will give different absolute numbers and probably a smaller routing win.
- The complexity heuristic is English-centric and misses hard prompts that look simple.
- Streaming is buffered; tool calls,
n > 1, images and embeddings endpoints are not supported. - The semantic cache's embedder is a hashing bag of words. A real embedding model would cut false hits but not remove the risk, which is why exact caching is the default.
- Tenant API keys live in the config file. They are compared by hash and never logged, but a production deployment would load them from a secret store.
- The Docker image is provided but CI does not build it.