llm-contract-router

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.

View on GitHub The example run

Plays the six stages below in order, about 45 seconds. Scroll or press Esc to stop.

Language
TypeScript, Node 22 or later
Tests
105 passing, no API key or network
Dependencies
None at runtime; vendor SDKs are optional
Providers
Anthropic and OpenAI adapters, or your own in one method
Contracts
Any zod-style safeParse, or a plain function
Registry
Not on npm yet; build it from source
License
MIT
npm run example answer : { sentiment: 'positive', score: 0.91 } served by: claude-haiku-4-5-20251001 (compact) degraded : true repairs : 1 cost : $0.000700

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.
Schema, sent pretty-printed over 22 lines
JSON Schema:
{"type":"object","properties":{"sentiment":{"type":"string","enum":["positive","negative","neutral"]},"score":{"type":"number","minimum":0,"maximum":1}},"required":["sentiment","score"]}
Output rules
- 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.

        Your code

        Exit taken

        1. Pass: hand the data to your code
        2. Fail with a repair left: send these lines back
        3. 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.

              1 package added0 runtime dependencies17.6 kB packed

              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.