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.
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
| Where | How it helps |
|---|---|
| AI agents | Route each step separately. Planning and debugging go to a strong model; summarizing, formatting and parsing tool output go to a cheap one. |
| Chatbots & support | Most messages are simple, and the few hard ones are escalated to a strong model automatically. |
| Batch & ETL pipelines | Extraction, classification and tagging over thousands of documents, with a hard cost cap on each call. |
| Multi-provider apps | Get the best-value model across OpenAI, Anthropic, Google and others, not one vendor's lineup. |
| Cost-sensitive products | Free 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.
Your task
A prompt from a user or an agent step, plus optional Limits.
Filter locally
- Context window fits input + output
- Max output ≥ what you need
- Estimated cost ≤ your budget
Jev decides
The remaining models go to Jev with live prices and limits, and Jev picks the most efficient fit.
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
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.
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.
Routing runs on Jev through OpenRouter's decisions API. OpenRouter can call every provider's models, so the same key also runs the chosen model.
router = Router(
openrouter_api_key="sk-or-...", # from openrouter.ai/keys
providers=["openai", "anthropic", "google"], # which providers are allowed
)
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
Routeris created, then shared by every router in the process for 24 hours. Callrefresh_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
| Call | What 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) → str | The best model id for task. |
await router.aroute(task, limits=None) → str | Async 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 | None | The 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. |
ModelInfo | One 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.
| Exception | When |
|---|---|
NoModelFitsError | No model passes the limits. The message gives the reason for each model. |
UnknownModelError | A model id or provider isn't in the catalog. |
RouterError | No 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.
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
| Item | Cost |
|---|---|
| Loading model data, and limit filtering | Free |
| 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
| Model | Input / 1M | Output / 1M | Context |
|---|---|---|---|
anthropic/claude-opus-5.5 | $4.00 | $20.00 | 1M |
google/gemini-3.5-flash-lite | $0.30 | $2.50 | 1M |
openai/gpt-6-luna | $0.10 | $0.50 | 1M |
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.
Coding agent
20k steps/month, ~4,000 in + 800 out tokens. 40% hard (design, debugging), 60% mechanical.
Document extraction
50k docs/month, ~3,000 in + 200 out tokens. 95% clean, 5% messy enough to need a strong model.
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_tokensare filtered out, so you don't pay for a cut-off answer and then again for the retry. - Hard cost caps.
max_cost_usdrules out any model whose estimated cost for this call is over budget.
Honest caveats
Limitations
- 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.