llm-gateway v0.1.0 · TypeScript · OpenAI-compatible

Send each LLM request to the cheapest model that can answer it, and keep serving when a provider fails.

A drop-in /v1/chat/completions gateway that routes across model tiers by task complexity, budget, cost cap and latency SLO, with fallback chains, retries, circuit breakers, response caching, per-tenant budgets and rate limits, and per-request cost telemetry. Runs with a deterministic mock provider, so everything on this page was produced without API keys.

  • 65% lowercost than always-premium
    ($0.44 vs $1.26 per 1k)
  • 684 msp50 latency
    vs 1910 ms premium
  • 86.0%quality proxy
    vs 98.3% premium

Routed policy, npm run bench, 300 prompts from the synthetic test split, simulated providers with illustrative prices. The quality proxy is a simulation; see how evaluation works.

See the results View on GitHub

Quality vs cost: fixed tiers and routed policies npm run bench: 300 prompts, synthetic test split, mock providers. Labels: quality, cost per 1k, p95 latency. 50% 60% 70% 80% 90% 100% $0.00 $0.20 $0.40 $0.60 $0.80 $1.00 $1.20 $1.40 $1.60 Cost per 1,000 requests (USD, illustrative prices) Quality proxy routed 0.6/0.9: 65.7% at $0.10 per 1k, p95 1.7 s routed 0.5/0.75: 72.7% at $0.18 per 1k, p95 2.2 s routed 0.4/0.65: 80.3% at $0.33 per 1k, p95 3.4 s routed 0.3/0.55: 82% at $0.34 per 1k, p95 3.4 s routed 0.25/0.5: 86% at $0.44 per 1k, p95 3.5 s routed 0.2/0.4: 88.3% at $0.49 per 1k, p95 3.5 s routed 0.1/0.3: 92.7% at $0.56 per 1k, p95 3.6 s always-cheap: 62% at $0.05 per 1k, p50 0.5 s, p95 1.0 s always-cheap 62.0%, $0.05, p95 1.0 s always-standard: 80% at $0.29 per 1k, p50 1.0 s, p95 2.0 s always-standard 80.0%, $0.29, p95 2.0 s always-premium: 98.3% at $1.26 per 1k, p50 1.9 s, p95 4.1 s always-premium 98.3%, $1.26, p95 4.1 s routed: 86% at $0.44 per 1k, p50 0.7 s, p95 3.5 s routed 86.0%, $0.44, p95 3.5 s routed + exact cache: 86% at $0.34 per 1k, p50 0.4 s, p95 3.4 s routed + exact cache 86.0%, $0.34, p95 3.4 s routed + semantic cache: 75.7% at $0.25 per 1k, p50 0.3 s, p95 3.1 s routed + semantic cache 75.7%, $0.25, p95 3.1 s Fixed tier Fixed tier Routed (default) Routed (default) Threshold sweep Threshold sweep Routed + exact cache Routed + exact cache Routed + semantic cache Routed + semantic cache

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).

PolicyQuality proxyCost / 1k reqp50p95Tier mix (cheap / std / premium)RetriesCache hits (false)
always-cheap62.0%$0.05506 ms1044 ms100% / 0% / 0%0-
always-standard80.0%$0.29955 ms2042 ms0% / 100% / 0%4-
always-premium98.3%$1.261910 ms4099 ms0% / 0% / 100%7-
routed86.0%$0.44684 ms3472 ms60% / 15% / 25%5-
routed + exact cache86.0%$0.34434 ms3351 ms59% / 16% / 26%384 (0)
routed + semantic cache75.7%$0.25315 ms3078 ms61% / 16% / 23%2129 (35)

Quality proxy by prompt category

Rows highlighted where routing loses 30 points or more against premium.

Categoryalways-cheapalways-standardalways-premiumrouted
classify100.0%100.0%100.0%100.0%
code46.7%66.7%100.0%66.7%
debug26.9%46.2%100.0%100.0%
design3.8%26.9%84.6%84.6%
extract90.9%95.5%100.0%90.9%
long-easy100.0%100.0%100.0%100.0%
math21.7%73.9%95.7%95.7%
qa100.0%100.0%100.0%100.0%
rewrite92.0%100.0%100.0%92.0%
sql41.7%83.3%100.0%83.3%
summarize71.9%96.9%100.0%90.6%
tricky4.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.

PolicyQuality proxyCost / 1k reqp50p95Tier mix (cheap / std / premium)RetriesCache hits (false)
routed 0.1/0.392.7%$0.561003 ms3623 ms38% / 28% / 34%4-
routed 0.2/0.488.3%$0.49721 ms3472 ms55% / 15% / 30%5-
routed 0.25/0.586.0%$0.44684 ms3472 ms60% / 15% / 25%5-
routed 0.3/0.5582.0%$0.34632 ms3351 ms66% / 17% / 17%4-
routed 0.4/0.6580.3%$0.33597 ms3351 ms70% / 13% / 17%3-
routed 0.5/0.7572.7%$0.18550 ms2196 ms75% / 21% / 4%2-
routed 0.6/0.965.7%$0.10528 ms1653 ms83% / 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

miss hit OpenAI-compatible client OpenAI-compatible client HTTP API auth, validation, SSE HTTP API auth, validation, SSE Tenant rate limit Tenant rate limit Response cache exact or semantic Response cache exact or semantic Router complexity, budget, cost cap, SLO Router complexity, budget, cost cap, SLO Budget ledger Budget ledger Executor fallback chain, retries Executor fallback chain, retries Circuit breaker per model Circuit breaker per model Provider adapters OpenAI-compatible, Anthropic, mock Provider adapters OpenAI-compatible, Anthropic, mock Telemetry JSONL events, Prometheus Telemetry JSONL events, Prometheus
Drawn at build time from the Mermaid diagram in the README.

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: true returns 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/completions compatible 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:

  1. An unmodified OpenAI SDK pointed at the gateway's base URL works for chat completions.
  2. 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.
  3. A request never starts if its worst-case cost exceeds the tenant's remaining budget.
  4. 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.
  5. A model whose breaker is open is skipped without spending an attempt.
  6. Cached answers are never shared across tenants and are only used for temperature-0 requests unless the client opts in.
  7. 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 tricky prompts 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.