An inference gateway that picks a model, proves the call is affordable before making it,
falls back when a provider fails, and explains every one of those decisions in the response —
section 3 of PLATFORM_SPEC.md.
The idea this repository exists to demonstrate: every routing decision is explainable, and the cost ceiling is enforced before the call rather than discovered after it.
Both halves are load-bearing. A router that picks a model but cannot say why is not observable, and the eval harness downstream scores routing decisions using exactly that explanation. A router that adds up costs after the responses arrive has not enforced a budget; it has written a receipt.
Zero runtime dependencies. Node 20.11+. TypeScript.
flowchart TB
cp["agent-control-plane<br/><i>InferenceRequest — spec §3</i>"]
subgraph dispatch["Router#dispatch — src/router.ts"]
direction TB
v["<b>1. validate</b><br/><i>validate.ts</i><br/>InvalidRequest names the field"]
p["<b>2. plan</b><br/><i>selection.ts · registry/</i><br/>hard filter → total deterministic rank"]
c{"<b>3. cache</b><br/><i>cache.ts</i><br/>sha256 of messages+model+params"}
subgraph gate["<b>4. gate</b> — per ranked candidate, no await anywhere in here"]
direction TB
g1["<b>a. THE COST CEILING</b><br/><i>money.ts</i> · estimate vs max_cost_usd"]
g2["<b>b. the breaker</b><br/><i>breaker.ts</i> · per provider"]
g3["<b>c. the ledger</b><br/><i>ledger.ts</i> · synchronous reserve"]
g1 --> g2 --> g3
end
d["<b>5. dispatch</b><br/>Transport.invoke · deadline · AbortSignal"]
s["<b>6. settle</b><br/>charge the real cost · record health · write cache"]
end
cp --> v --> p --> c
c -->|"hit — free, nothing charged"| out
c -->|"miss"| g1
g3 -->|"reserved"| d --> s --> out
d -.->|"failure — release the hold in full"| fb["<b>7. fallback</b><br/>next ranked candidate<br/>fallbacks_used += 1"]
fb -.-> g1
g1 -.->|"over_ceiling"| refuse
g2 -.->|"circuit_open"| refuse
g3 -.->|"over_budget"| refuse
out["InferenceResponse<br/><b>+ route explanation</b>"]
refuse["BudgetExceeded · CircuitOpen<br/>NoEligibleModel · AllProvidersFailed<br/>UpstreamTimeout<br/><b>+ route explanation</b>"]
out --> tel["span — spec §5 → agent-telemetry"]
refuse --> tel
classDef gateBox fill:#7c2d12,stroke:#fb923c,color:#fff
classDef exit fill:#14532d,stroke:#22c55e,color:#fff
classDef bad fill:#7f1d1d,stroke:#ef4444,color:#fff
class g1,g2,g3 gateBox
class out exit
class refuse bad
Steps 4a to 4c all happen before the transport is touched, and src/router.ts lines 178
to 271 — the entire path from the start of dispatch to the reservation — contains no
await at all. That gap is where a concurrency bug would live, so there isn't one. Ten
concurrent route() calls each run synchronously through their reservation before any of
them suspends.
Full reasoning in ADR 0001.
Cost is input_tokens × input_price + output_tokens × output_price. Before the call, only
one of those four numbers is known for certain. This router makes a second one certain: it
reserves an explicit output allowance and hands the transport the same cap it reserved
budget for. A provider that honours the cap cannot produce a call that costs more than the
estimate on the output side.
Rounding points the same way. Estimates round up, budgets and ceilings round down, settled actuals round to nearest — so rounding can never make a call look affordable when it is not (ADR 0002).
The input side is exact only relative to the injected TokenEstimator. The default is a
character heuristic that leans high (3.5 characters per token, against the ~4 English prose
usually gets); inject a real tokenizer to make it exact. Where the provider's own count
disagrees, settle charges the real number and the difference is recorded as an
overdraft — visible in the ledger snapshot, and enough to refuse the run's next call. A
breach that gets absorbed silently is worse than one that gets charged loudly.
test/signature.budget.test.ts is written to break the claim, not to demonstrate it. Each
test names the specific way the guarantee could be fake:
| Test | The lie it is looking for |
|---|---|
refuses without calling any provider when the estimate breaches max_cost_usd |
the check happens, but the call went out anyway — asserts transport.callCount === 0 |
refuses on the estimate even when the call would actually have come in under the ceiling |
the "check" is a post-hoc reconciliation wearing a hat — the scripted provider would have returned 10 output tokens |
explains the refusal: route.chosen is null and the numbers are in the error |
a refusal that cannot be explained |
caps the provider call at the same output allowance it reserved budget for |
the estimate is a guess, because nothing bounds the output |
lets through only as many concurrent calls as the run budget can afford |
the ledger is not atomic — 10 concurrent requests, budget for 3.5, transport gated open mid-flight |
holds the line when many runs share one router |
one run's spend leaks into another's budget |
reports cache_hit, charges nothing, and does not call the provider |
a free path is charged |
serves a cache hit even when the run has no budget left |
a free path is gated by a check that exists to stop spending |
records the overdraft and refuses the next call |
a provider that ignores the cap invalidates the check silently |
is refused before any call when requireRunBudget is set |
an undeclared budget quietly means unlimited |
The concurrency test asserts more than a count: exactly 3 of 10 reach the provider, 7 reject
with BudgetExceeded, reserved never exceeds the budget mid-flight, every reservation is
released or settled afterwards, and spent + remaining == budget exactly, in integer
nano-USD.
Checked by mutation, not just by passing. Changing the one line in CostLedger#reserve
that subtracts in-flight reservations — the classic version of this bug, where only settled
spend counts — fails exactly 3 of the 116 tests, 2 of them in the signature file:
lets through only as many concurrent calls as the run budget can afford (10 calls reached
the transport instead of 3) and holds the line when many runs share one router (4 instead
of 2). The line was then restored and the suite re-run green.
The lint was verified the same way, against a file carrying one violation of each rule.
Five repositories, one control plane. This is the inference leg.
flowchart LR
cp["<b>agent-control-plane</b><br/>Python<br/>runs, DAG, budgets"]
mr["<b>model-router</b><br/>TypeScript<br/><i>you are here</i>"]
sb["agent-sandbox<br/>Python"]
tl["<b>agent-telemetry</b><br/>Python<br/>span schema"]
ev["<b>agent-evals</b><br/>Python<br/>replay · score"]
cp -->|"§3 InferenceRequest<br/>→ response or typed error"| mr
cp -->|"§4 actions"| sb
mr -->|"§5 inference span"| tl
sb -->|"§5 action span"| tl
tl -->|"§6 recorded run, JSONL"| ev
ev -.->|"scores route from §3"| mr
classDef me fill:#1e3a8a,stroke:#60a5fa,color:#fff
class mr me
| Contract | Section | What this repository implements |
|---|---|---|
| Identifiers | §1 | Consumes run_id, step_id, trace_id as opaque strings; mints span_id as 16 hex, the OTel format. Nothing is an autoincrement. |
| Run state machine | §2 rule 3 | "A run enters BUDGET_EXHAUSTED when the router reports the budget ledger would go negative. The check happens before the call, not after." This is the sentence the repository is built around. The router reports it as BudgetExceeded; the transition itself belongs to the control plane. |
| Inference contract | §3 | The whole of it. Request shape, response shape, the mandatory route object, and the five typed error names — NoEligibleModel, BudgetExceeded, AllProvidersFailed, UpstreamTimeout, CircuitOpen, each set as the class's name so a Python consumer can switch on the serialised form. |
| Span schema | §5 | Emits one kind: "inference" span named model_router.route per request, with cost_usd as a first-class attribute so a run total is a sum over spans. Both of the section's enforced-in-code rules: bodies withheld by default, and whatever was withheld named in redacted. |
| Recorded-run format | §6 | Carries attributes.request_digest as sha256:… so a recorded inference correlates on replay, and keeps candidate ordering total and deterministic so a replay reaches the same decision. |
Talks to: agent-control-plane (inbound, §3) and agent-telemetry (outbound, §5).
Does not talk to: agent-sandbox — both are called by the control plane and neither
calls the other. agent-evals reads this router's decisions second-hand, through the
recorded run.
Takes no dependency on any of them. The span shape is duplicated as a local interface in
src/spans.ts rather than imported, which is the price of the empty dependencies block and
is paid deliberately.
npm install # devDependencies only: typescript, @types/node
npm run ci # clean, lint, typecheck, build, test ← the whole gate
npm test # build, then node --test
npm run demo # a narrated offline tour, examples/demo.ts
import { Registry, Router } from 'model-router';
const router = new Router({
registry: Registry.fromFile('./my-catalog.json'),
transports: [anthropicTransport, openaiTransport], // you write these
breaker: { failureThreshold: 5, cooldownMs: 30_000 },
spans: mySpanSink,
});
router.openRun('run_01J...', 0.25); // USD for the whole run
const response = await router.route({
run_id: 'run_01J...',
step_id: 'step_01J...',
trace_id: '4bf92f3577b34da6a3ce929d0e0e4736',
messages: [{ role: 'user', content: '...' }],
requirements: {
capabilities: ['tool_use', 'json_mode'],
max_cost_usd: 0.05,
max_latency_ms: 30_000,
prefer: 'cost',
},
cache: { mode: 'read_write' },
});Every response carries the explanation:
response.route;
// {
// considered: ['citrine-fast', 'acme-small', 'borealis-pro', 'acme-large'],
// chosen: 'citrine-fast',
// reason: 'cheapest of 4 eligible candidates meeting capabilities [tool_use, json_mode] '
// + 'and latency ceiling 30000ms; estimated $0.003085 for 25 input tokens and up to '
// + '2048 output tokens, checked before the call',
// fallbacks_used: 0,
// rejected: [{ model: 'borealis-lite', reason: 'missing capability tool_use' }, ...],
// skipped: [],
// strategy: 'cost',
// }route is on the failures too. RouterError has a route field and a toJSON(); a
BudgetExceeded reports chosen: null plus every estimate and the ceiling each one
breached. A refusal you cannot explain is as opaque as a choice you cannot explain.
interface Transport {
readonly provider: string;
invoke(request: TransportRequest): Promise<TransportResult>;
}It is also the only thing that touches a network. TransportRequest carries
max_output_tokens — the cap the estimate paid for; forward it to the provider or the
bound becomes a guess — a deadline_ms, and an AbortSignal that fires when the deadline
passes. Reasoning in ADR 0003;
the obligations this places on your transport are in
the threat model, T8.
Pricing is data, in src/registry/models.json, and src/registry/catalog.ts is the only
file in the package that reads it. A test fails the build if any other source file so much as
mentions usd_per_mtok.
The shipped catalogue is synthetic: invented providers (acme, borealis, citrine)
and placeholder prices chosen to give the strategies a meaningful spread. Every entry says so
in pricing.pricing_source, Registry#placeholderPricedModels() lists them, and
new Router({ requireRealPricing: true }) refuses to start while any remain. Supply your own
with Registry.fromFile(path).
| Field | Meaning |
|---|---|
capabilities |
hard filter against requirements.capabilities |
pricing |
per-million-token input and output prices, plus their provenance |
typical_latency_ms |
hard filter against max_latency_ms, and the latency score |
max_output_tokens |
the output allowance reserved when the request names none |
context_window |
hard filter against estimated input plus output allowance |
quality_score |
operator-declared preference weight for prefer: 'quality'. Not a measurement — this router does not benchmark models |
Two phases, kept apart so the decision stays explainable. The hard filter (capabilities,
latency ceiling, context window, whether a transport exists at all) removes models with a
reason attached; they appear in route.rejected. The survivors are put in a total,
deterministic order by prefer — every comparator ends in a model-id tie-break, so the
same request against the same registry always routes the same way, which is what makes a
recorded run replayable.
Cost is deliberately not a hard filter. Affordability depends on what the run has already
spent, so it is decided against the ledger at dispatch and reported in route.skipped with
the numbers attached — otherwise "your ceiling is too low" arrives disguised as "nothing
matched your capabilities". ADR 0004.
What happens when a dependency is down, slow, or lying.
| What breaks | What happens |
|---|---|
| A provider returns an error | The attempt fails, the reservation is released in full (nothing charged), the breaker counts it, and the chain walks to the next ranked candidate. route.fallbacks_used counts the walk. |
| A provider hangs | The per-attempt deadline (max_latency_ms, else defaultTimeoutMs = 60s) fires an AbortController. Whichever promise wins the race, a fired deadline is reported as UpstreamTimeout rather than as whatever the HTTP client called it. A lone breach surfaces as UpstreamTimeout; with somewhere to fall back to it is just another failure in the chain. |
| A provider keeps failing | Five consecutive failures (default) open its breaker. Further calls are refused without being made until a 30s cooldown admits exactly one probe — not the whole backlog. The probe closes it or re-opens it with the cooldown restarted. Every transition is published to onBreakerTransition with from, to, reason, at. ADR 0005. |
| Every candidate is behind an open breaker | CircuitOpen, no call made. |
| Every candidate is unaffordable | BudgetExceeded, no call made, every estimate and the ceiling in the explanation. |
| The chain is exhausted | AllProvidersFailed, with each attempt's model, provider and error in details.failures. |
| A provider lies about usage, or ignores the output cap | The real cost is charged, the excess is recorded as overdraft_usd, remaining_usd goes negative, and the run's next call is refused. Charged loudly rather than absorbed. Under-reporting is believed — see threat model T2. |
| The registry lists a model with no transport | Hard-rejected at plan time with no transport is registered for provider X, visible in route.rejected, rather than surfacing as a surprise at dispatch. |
| Nothing in the registry fits | NoEligibleModel, with a per-model reason for every rejection. |
| The catalogue is malformed | CatalogError at load, naming the offending field. The router never starts on half a catalogue. |
| The caller sends a bad request | InvalidRequest, naming the field. Deliberately not a RouterError, so isRouterError(e) never swallows a caller bug. |
Every row is asserted in the suite; none of it is aspiration.
Every request emits one span shaped to spec section 5 (kind: "inference",
name: "model_router.route"), carrying the routing decision, the usage, the cost and the
request digest. Two of the section's rules are enforced in code rather than documented as
advice:
- Prompt and response bodies are not exported by default.
capturePrompts: trueopts in. - What was withheld is named in
redacted, so the gap is visible rather than silent.
router.breakerSnapshot() and router.ledgerSnapshot(run_id) are readable at any time; the
ledger snapshot reports both USD and exact integer nano-USD, and the nano fields are the
authoritative ones.
Offline. No network, no credentials, no live model, nothing to mock at the HTTP layer: time
is a ManualClock and providers are FakeTransports. test/hygiene.test.ts makes that a
property rather than a promise — it asserts that nothing under src/ imports anything but
node: builtins and relative paths, and that no network API (fetch, node:http,
node:net, node:dns, node:tls, node:dgram) is reachable from the source at all.
$ npm run ci
lint: 33 files, 12 rules, no findings
...
ℹ tests 116
ℹ suites 25
ℹ pass 116
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 447.3728
116 tests, 25 suites, 0 failures. Size, from find src -name "*.ts" | xargs wc -l:
2,243 lines of TypeScript across 18 source files, plus a 97-line JSON catalogue. Tests are a
further 1,960 lines across 10 test files and one fixture module.
.github/workflows/ci.yml, on Node 20.x and 22.x. Every step
was run locally before it was written into the workflow:
| Step | Command | Can it fail? |
|---|---|---|
| lint | npm run lint |
Yes — verified against a probe file violating six rules; exit 1, six findings |
| types | npm run typecheck |
Yes — strict, noUnusedLocals, noUnusedParameters, noUncheckedIndexedAccess, exactOptionalPropertyTypes |
| build | npm run build |
Yes |
| tests | node --test "dist/test/**/*.test.js" |
Yes — verified by mutating CostLedger#reserve; 3 failures |
| dependency audit | npm audit --omit=dev --audit-level=low |
Yes — currently an empty runtime tree, 0 vulnerabilities, so this asserts it stayed empty |
| example | node dist/examples/demo.js |
Yes |
No badge, because there is no remote for it to point at.
The linter is scripts/lint.mjs, a zero-dependency script holding twelve invariants a
type-checker cannot express — among them no console in a library, no any, no focused
test, no TODO marker, no process.env read in src/, relative imports carrying their .js,
and a module header on anything that declares something. Not ESLint, which would have meant a
dependency and a config
(ADR 0003).
docs/adr/— five decisions where a real alternative was rejecteddocs/threat-model.md— assets, seven trust boundaries, thirteen threats, and which of them are not mitigated
Built from what was actually not done.
- No linter in the conventional sense.
scripts/lint.mjsenforces this repository's own invariants and tsconfig covers the generic ones, but neither is ESLint, and neither does type-aware analysis of the kind@typescript-eslintwould. - In-memory, single-process. The ledger, the cache and the breakers live in one process.
Two gateway instances each enforce the budget they can see, which is not the same as
enforcing it once.
ResponseCacheis an interface you can back with something shared; the ledger and breakers are not yet abstracted that way. - Run accounts have no TTL, eviction or persistence. They grow until
closeRunis called, and a restart forgets every budget. - No cache stampede protection. N identical concurrent requests all miss and all call the provider. Single-flight would fix it and is not implemented.
- The input-token estimate is a heuristic unless you inject a tokenizer. The output side of the bound is exact; the input side is exact only relative to the estimator. The overdraft mechanism is what catches the difference.
- No streaming.
Transportreturns a complete result. Streaming would move output-cap enforcement from a request parameter to mid-stream accounting, which deserves its own design rather than a flag. - No retries within a provider. A failure falls back to the next candidate. Retrying the same provider after a backoff on a 429 is a real strategy and is not implemented.
- The deadline is per attempt, not per request, so a three-candidate chain can take three
times
max_latency_msin the worst case. - The breaker counts consecutive failures, not a failure rate. A provider failing 40% of calls may never trip, because one success resets the counter.
parent_span_idis always null. The section 3 contract passes atrace_idbut not the control plane's step span id, and the contract was not extended unilaterally.- Cache lookup is exact-match only, probing candidates in rank order. No semantic or prefix similarity, and nothing clever about which cached model would have been preferable.
- The shipped catalogue is synthetic. Invented providers, placeholder prices, every entry
declaring
pricing_source: "placeholder". No claim is made anywhere in this repository about any real provider's pricing. - Three security gaps are known and unfixed, documented rather than quietly carried:
a transport error message reaches the span stream verbatim and is not redacted (T8); a
catalogue priced at zero passes validation and silently disables every ceiling (T5); a
SpanSinkthat throws turns a paid, settled call into an error (T11). Seedocs/threat-model.md.
MIT. See LICENSE.