model-router-python
Limit-aware LLM routing, powered by Jev

Send every prompt to the cheapest model that can actually do the job.

You list the models or providers you can use. For each task, model-router-python rules out models that can't fit it or afford it, using live prices, then lets Jev pick the most efficient one. Pure Python, zero dependencies.

$pip install model-router-python
"Translate 'good morning' into French."cheap openai/gpt-6-luna
"Summarize this email in one sentence."cheap openai/gpt-oss-20b
"Design a lock-free hash map in Rust and prove it's linearizable."strong anthropic/claude-opus-5.5

Real routing decisions from the repo's examples, run against live APIs.

58–92%cost saved in the example workloads
0runtime dependencies (stdlib only)
~$0.00003per routing call with a 3-model list
Liveprices & limits, cached for 24h

Overview

What it is

model-router-python is a small Python library that answers one question for every LLM call: which model should handle this? It returns a model id. You then call that model however you already do.

python
from model_router import Router

router = Router(
    openrouter_api_key="sk-or-...",              # or jev_api_key="jev_..."
    providers=["openai", "anthropic", "google"],   # every current model from these
)

router.route("Translate 'good morning' into French.")
# -> "openai/gpt-6-luna"

router.route("Design a lock-free hash map in Rust and prove it's linearizable.")
# -> "anthropic/claude-opus-5.5"

The problem

Why use it

Most apps and agents send every request to one flagship model, but most requests are routine: extract a field, rephrase a sentence, answer an FAQ. Flagship models cost up to 40× more per token than small ones.

Stop overpaying

Easy prompts go to models that cost a fraction as much. Hard ones still reach a strong model.

No failed calls

Models whose context window or output limit is too small are ruled out before any call is made.

Always current

New models and price changes are picked up from live data. No code changes, no hand-maintained model tables.

Where to use it

WhereHow it helps
AI agentsRoute each step separately. Planning and debugging go to a strong model; summarizing, formatting and parsing tool output go to a cheap one.
Chatbots & supportMost messages are simple, and the few hard ones are escalated to a strong model automatically.
Batch & ETL pipelinesExtraction, classification and tagging over thousands of documents, with a hard cost cap on each call.
Multi-provider appsGet the best-value model across OpenAI, Anthropic, Google and others, not one vendor's lineup.
Cost-sensitive productsFree tiers and high-volume endpoints where a fraction of a cent per request adds up.

Under the hood

How it works

Each route() call runs three steps. The first is free and runs locally. The second is skipped when only one model is left.

1

Your task

A prompt from a user or an agent step, plus optional Limits.

2

Filter locally

  • Context window fits input + output
  • Max output ≥ what you need
  • Estimated cost ≤ your budget
3

Jev decides

The remaining models go to Jev with live prices and limits, and Jev picks the most efficient fit.

4

Model id

"openai/gpt-6-luna". Call it with your own client.

Get going in a minute

Quick start

Requires Python 3.12+. You pass every key in code, so no .env file or environment variable is required.

pip install model-router-python      # or: uv add model-router-python
python
from model_router import Router, Limits

router = Router(
    openrouter_api_key="sk-or-...",
    models=["anthropic/claude-opus-5.5", "openai/gpt-6-luna", "google/gemini-3.5-flash-lite"],
)

model = router.route("Summarize this paragraph: ...")

# Per-call limits: how much output you expect, and a hard cost cap in USD
model = router.route("Write a detailed design doc ...", Limits(output_tokens=4000, max_cost_usd=0.05))

Two setups

Configure

Pick one routing key, then tell the router which models it may choose from.

Routing runs on Jev's model-route API. Pass the providers you already pay for. The router returns your key for whichever model it picks, so you call that provider directly.

python
router = Router(
    jev_api_key="jev_...",                       # from jevai.org/agent/keys
    providers={
        "openai": "sk-...",
        "anthropic": "sk-ant-...",
    },
)

model = router.route(task)          # e.g. "openai/gpt-6-luna"
key = router.api_key_for(model)     # your OpenAI key

Rather not hand the router your provider keys? Pass just the names: providers=["openai", "anthropic"]. Provider keys are never sent over the network.

Choosing candidate models

providers=[...]

Every current model from those providers. :batch and :free variants are skipped, and so are models scheduled for retirement. Narrow the list with models_per_provider=N (newest first).

models=[...]

An exact list of OpenRouter-style ids. This replaces the automatic pick and gives you the shortest, cheapest routing requests.

If you pass both jev_api_key and openrouter_api_key, routing goes through Jev directly.

Always up to date

Live model data

Every candidate is sent to Jev as one compact line of live data from OpenRouter's public model list:

anthropic/claude-opus-5.5 | $4.00/M in, $20.00/M out | context 1000000 | max output 128000 | est. $0.020500 for this task | Claude Opus 5.5 is Anthropic's flagship…
  • Fetched once, not per request. The list is downloaded when the first Router is created, then shared by every router in the process for 24 hours. Call refresh_catalog() to download it again.
  • No key needed for pricing. Providers' own APIs don't publish prices, so prices, context sizes and output limits come from OpenRouter's public list. This works with either option.
  • Fits Jev's 32 KiB request limit. Descriptions shrink automatically when there are many candidates, but prices and limits are always kept. Three full providers (102 models) come to about 24 KB.

Reference

API reference

CallWhat it does
Router(*, jev_api_key=None, openrouter_api_key=None, providers=None, models=None, limits=Limits(), models_per_provider=None, timeout=60)Keyword-only. Loads live model data (cached for 24h) and works out the candidate list. timeout is the HTTP timeout in seconds for each request. Raises UnknownModelError for unknown model ids or providers.
router.route(task, limits=None) → strThe best model id for task.
await router.aroute(task, limits=None) → strAsync route() for asyncio apps; doesn't block the event loop.
router.fitting(task, limits=None) → list[ModelInfo]The models that pass the limits, without calling Jev (free).
router.api_key_for(model_id) → str | NoneThe key you passed in providers={...} for that model's provider.
Limits(output_tokens=1024, max_cost_usd=None)The output size you expect and an optional cost cap for each call.
refresh_catalog()Clears the cached model list, so the next Router downloads fresh prices.
ModelInfoOne model's data: id, context length, max output, prices per token, description.

Errors

All errors are subclasses of RouterError, so one except clause covers every routing failure.

ExceptionWhen
NoModelFitsErrorNo model passes the limits. The message gives the reason for each model.
UnknownModelErrorA model id or provider isn't in the catalog.
RouterErrorNo routing key, a network or HTTP failure, or an error returned by Jev (e.g. a rate limit).

Patterns

Using it in agents

Route every LLM call separately. A planning step and a formatting step rarely need the same model. Keep a fallback so a routing outage never stalls the agent.

python
from model_router import Limits, NoModelFitsError, RouterError

FALLBACK = "openai/gpt-6-luna"

def pick(router, prompt, limits):
    try:
        return router.route(prompt, limits)
    except NoModelFitsError:
        raise                 # input too large for every model: shrink it
    except RouterError:
        return FALLBACK       # Jev down or rate limited: keep going

# Multi-turn chat: route on the whole transcript so the context check grows with it
transcript = "\n".join(f"{m['role']}: {m['content']}" for m in messages)
model = pick(router, transcript, Limits(output_tokens=2000))

The repo's examples/agent.py shows plan-and-execute with a total budget spread across the steps, and a chat that switches to a stronger model when the question gets hard.

Transparent pricing

Costs

The router itself

ItemCost
Loading model data, and limit filteringFree
Only one model passes the limits (Jev is skipped)Free
One routing call via OpenRouter, 3 models (measured)$0.000027
One routing call via OpenRouter, 102 models from 3 providers (measured)$0.00045
1,000 routing calls (3 models / 102 models)$0.027 / $0.45

Routing cost grows with the number of candidates, because Jev reads each model's price and limits every time. For high-volume traffic, pass a short models=[...] list. Only the first 8,000 characters of a task are sent, so a single routing call costs at most about $0.0001 extra however long the prompt.

The models it picks between

ModelInput / 1MOutput / 1MContext
anthropic/claude-opus-5.5$4.00$20.001M
google/gemini-3.5-flash-lite$0.30$2.501M
openai/gpt-6-luna$0.10$0.501M

OpenRouter prices, September 2026. The router always uses live prices, not this table.

Real-world estimates

Use cases

All-flagship vs. routed, using the prices above plus the cost of routing. The share of easy vs. hard tasks is an assumption, so measure it on your own traffic before relying on these numbers.

Customer support bot

100k messages/month, ~800 in + 300 out tokens. 85% routine, 15% need careful reasoning.

All Opus
$920
Routed
$160
83% ≈ $760/month saved

Coding agent

20k steps/month, ~4,000 in + 800 out tokens. 40% hard (design, debugging), 60% mechanical.

All Opus
$640
Routed
$268
58% ≈ $372/month saved

Document extraction

50k docs/month, ~3,000 in + 200 out tokens. 95% clean, 5% messy enough to need a strong model.

All Opus
$800
Routed
$64
92% ≈ $736/month saved

Where the savings come from

  • Cheaper tokens, not fewer tokens. Your prompt is the same size either way. Easy prompts go to models that charge up to 40× less per token.
  • No wasted calls. Prompts that won't fit a model's context window are rejected before any request is sent.
  • No truncated answers. Models whose output cap is below your output_tokens are filtered out, so you don't pay for a cut-off answer and then again for the retry.
  • Hard cost caps. max_cost_usd rules out any model whose estimated cost for this call is over budget.

Honest caveats

Limitations

Option A (Jev key) is covered by unit tests but not yet checked against the live API. Every test call so far got HTTP 429. Option B (OpenRouter key) is verified end to end. Tracked in #11.
  • Token counts are estimated at about 4 characters per token, so the context and cost checks are approximate. Leave some headroom (#15).
  • Returned ids use OpenRouter's naming. A provider's own name can differ, e.g. Anthropic's API uses claude-opus-5-5 (#16).
  • route() returns only the model id, not Jev's confidence (#14).
  • Routing adds one network call before each model call, except when only one model passes the limits.

Help build it

Issues are labelled by difficulty and area, and each one says what "done" looks like. Pick a good first issue, ask a question in Discussions, or open a new issue.