A TypeScript library that routes a request across LLM providers, checks each answer against your schema, and tells the model which rule it broke before retrying or moving to the next model.
Falling back to a cheaper model is easy. Falling back safely is not: the weaker model returns prose where JSON was asked for, or drops a required field, and the failure lands in your application. So the hold line here protects your code from the model, not the other way round. An answer reaches the caller only after it has passed the contract.
Fallback is the repo's own example, output verbatim. Refused is a short script I ran against the built library with mock providers. No key ran with no key in the environment, the second command in an empty project that installed only the packed library; stack traces are trimmed and paths shortened. No model was called for real anywhere on this page.
1Shape the request for the model's tier
Every model in the chain carries a tier: frontier, balanced or compact. The tier decides how much scaffolding its request gets, so the same complete() call produces a different request for each model.
Every tier gets the schema. Balanced and compact also get four output rules, and only compact gets a worked example. Temperature drops from 0.2 to 0.1 to 0.
The worked example is generated from the JSON Schema itself, and a test checks that it passes the contract. For a shape the generator doesn't understand it gives no example rather than a wrong one.
A caller can override one tier, say compact's temperature, without redefining the rest.
The asymmetry is deliberate. A compact model given a terse prompt returns prose; a frontier model given all the hand-holding does worse, because the scaffolding crowds out the task.
One contract, three requests
Task and schemaOutput rulesWorked example
System prompt for compact, at temperature 0
Task
You produce a single JSON value conforming to the "sentiment" schema.
- Return only the JSON object. No prose before or after it.
- Do not wrap the JSON in markdown code fences.
- Include every required field. Do not invent fields that are not in the schema.
- If a value is genuinely unknown, use the schema's allowed representation for it rather than omitting the field.
Worked example, generated from the schema
An example of the required shape. Copy the structure, not the values:
{
"sentiment": "positive",
"score": 0
}
Prompt text and character counts are the project's buildSystemPrompt output for the example's sentiment contract; the schema is shown on one line here. Re-running the README's example and reading what the mocks recorded: the router sent the frontier model 395 characters at temperature 0.2 and the compact model 814 at 0. No paid calls.
2Call the model, fall over on provider errors
Each call runs under its own deadline, 30 seconds by default. When a call fails, the adapter has already translated the vendor's error into one small vocabulary, so the routing logic never sees a vendor error.
A provider failure moves straight to the next model, with no repair. A repair can fix a bad answer, not a 429; a test checks the primary is called once even when two repairs are allowed.
A model whose provider was never registered is skipped, not fatal, so one bad entry can't take out the rest of the chain.
Before every call, the spend so far is checked against an optional budget, maxCostUsd.
Keys come only from the apiKey option or the vendor's environment variable. A missing key fails when the provider is created, before any request.
What a failed call becomes
The mapping is the adapters' classifyStatus, with the status codes its tests check, and the descriptions are the code's own. The run underneath is my script against the built library: the primary is a mock that never answers and the timeout is 20 ms, so the router moves on. Mock providers, no paid calls.
3Pull the JSON out of the reply
Telling a model to return only JSON doesn't make it so, and the weaker the model the less it holds. So the parser tries the readings in order, cheapest first, and keeps the first that parses.
The whole reply, then a fenced code block, then the first balanced {…}, then the first balanced […].
The balanced-span scanner skips braces and escaped quotes inside strings. A regex can't: in {"note":"}"} the first closing brace is inside a string.
When nothing parses, that becomes a problem the model is shown on the repair, like any broken rule.
Seven replies Illustrative input
ParsedTried, didn't parseNothing of that kind in the reply
The replies are made up. Each was run through the project's extractJson and its candidate generator, and the readings tried, the values and the messages are its real output. ↵ marks a line break.
4Check the answer against the contract
This is the hold line, and it protects your code: nothing a model says gets past it unless it satisfies the contract. A contract is two things, a schema the model is shown and a validate function that decides.
Any validator with a zod-style safeParse fits, or a hand-written function, because the interface is structural. The library depends on no validator; its tests use real zod 4 and a hand-written contract.
Validator errors become path: message lines, because “invalid input” gives a model nothing to fix.
Three exits: pass and hand the data over; fail with a repair left and send the problems back; fail again and try the next model. Every rejection is recorded with its problems, tokens and cost.
One reply at the hold line
The model's reply, parsed Illustrative input
Kept for the repair prompt
These constraints were not met:
None. Every field passed.
contract.validate()
Your code
Exit taken
Pass: hand the data to your code
Fail with a repair left: send these lines back
Fail again: try the next model
The replies are made up; the first is the one the README's example scripts for its backup model. Every verdict and message is real output: the example's hand-written contract and zod 4 through fromSafeParser, run on these values. No model was called.
5Repair once, naming the broken rule
A rejected answer never crosses. The model is told what it produced and exactly which constraints it broke, then asked once more. Asking again without that just re-rolls the same mistake.
The repair prompt carries the original ask, the model's own reply, cut at 2,000 characters, and one line per broken rule.
One repair per model by default. If the repair fails too, the router stops spending on a model that can't comply and moves on.
The next model starts clean from the original prompt and never sees the failed repair.
One test says it in its name: the router “tells the model what was wrong, not just that it was wrong”.
What the router sent back Illustrative input
Prompts are what the router actually sent, recorded by the mock provider (provider.calls): Repair works re-runs the README's example, Repair fails is my script where the primary repeats its mistake. Attempt lines are printed in the example's format. Mock providers, no paid calls.
ClearedOnly an answer that passed the contract crosses here. Everything above happens inside the router; below this line is your code, which gets typed data or a typed failure, never a malformed answer.
6Hand back typed data or a typed failure
Your code gets { ok: true, data, meta } with the data typed and validated, or { ok: false, error, meta } where the error is chain_exhausted, budget_exceeded or configuration. meta matters most when it failed, so it travels in the result rather than on an exception.
meta.attempts lists every call: model, tier, repair number, outcome, failure kind, problems, tokens, cost and latency.
degraded means a model other than the primary served it; repairs counts repair calls. Two fields, two signals.
costUsd sums every attempt, repairs and abandoned models included, so a fallback can cost more than the primary alone.
Four runs, four results
meta.costUsd$0.000000
Primary alone, first try: $0.005250
Spent on rejected answersSpent on the accepted answer
Values are the real results of four runs with mock providers, no paid calls: the README's example, and three runs of my script against the built library. Each mock call reports 100 input and 50 output tokens, and the prices are the example's inputs ($15 and $75 per million tokens for the primary, $1 and $5 for the backup), not current list prices.
The README's run, traced
The repo's example scripts two mock providers: a primary that rate-limits, and a backup whose first answer breaks the contract. Hover or focus a line of its output to see which part of the script caused it.
examples/degrade.ts, lines 48 to 60
npm run example
Hover or focus a line above.
Measured, not assumed
The repo documents no benchmarks, so none are quoted here. These numbers come from running the project's own code with mock providers on my machine.
A fallback can cost more
The total counts every call, repairs and abandoned models included.
Primary, first try$0.005250
README example$0.000700
Repair fails, backup$0.010850
Every model fails$0.011200
A failed repair on the expensive model plus a fallback came to about twice the primary alone. The README says as much: you are buying a valid answer with extra round trips. Mock token counts, the example's prices.
The budget is a soft ceiling
maxCostUsd is checked before each call, against what has already been spent.
Budget$0.005000
Spent$0.005250
So a run stops at the first check after reaching the budget, and can pass it by up to one call. Measured: a $0.005000 budget returned budget_exceeded after $0.005250. No call starts once the budget is reached.
One package, nothing else
Packed from the repo and installed into an empty project, offline.
The vendor SDKs are optional peer dependencies behind their own entry points. Importing the Anthropic adapter without its SDK fails at import with Cannot find package '@anthropic-ai/sdk', naming what to install.
105 tests, none needs an API key
Every provider is a mock or a fake client. CI sets no provider key at all, so the suite passing there shows nothing reaches a real API.
Weighted toward what fails quietly
Routing: falls over on four failure kinds without a repair, reports every model when the chain runs out, skips an unregistered provider, and turns a provider that never answers into a timeout.
The contract: repairs instead of giving up, tells the model what was wrong, moves on when the repair fails, honours repairsPerModel: 0, and keeps degraded and repairs separate.
Calibration: each tier gets a different request, the worked example passes its own schema, a self-referential schema still terminates, and a runaway answer is cut before it's echoed back.
Parsing: fences, prose, nesting, braces and escaped quotes inside strings, and four replies it must refuse.
Adapters: the Anthropic and OpenAI wire shapes against fake clients, and HTTP status codes mapped to the router's vocabulary.
zod: the README's zod path, end to end with real zod 4.
CI runs build, typecheck, lint, the tests, coverage and the README example on Node 22 and 24.
Deliberately missing
Streaming
The contract is checked against a complete response, so there is nothing to validate mid-stream.
Backoff, caching and batching
No retries within a model beyond the one repair. A 429 falls over at once; backoff belongs on the vendor client you pass in.
Quality measurement
The router trusts the tier you assign and won't notice a model getting worse. Tier is a judgement, not a benchmark. Measuring that is a different tool, noisefloor.
Shipped prices
Model identities and tiers ship; prices don't, because a stale price that looks authoritative is worse than none. You pass current prices with withPricing.
Run it
git clone https://github.com/ahmed-hashim-pro/llm-contract-router.git
cd llm-contract-router
npm ci
npm run check # build, typecheck, lint, 105 tests
npm run example # the fallback run, no key needed# It isn't on npm. To use it in an app, pack the build:
npm pack
cd ../your-app
npm i ../llm-contract-router/llm-contract-router-0.1.0.tgz
npm i @anthropic-ai/sdk # only for llm-contract-router/anthropic
npm pack ships the dist/ folder that npm run check builds, so run that first. Installing straight from GitHub won't work: dist/ isn't committed and no install script builds it. Keys are read only from the apiKey option or ANTHROPIC_API_KEY and OPENAI_API_KEY.