OpenRouter puts one API key and one base URL in front of
models from dozens of vendors. On the day this was written its catalogue held 417
models, 17 of them free to call. Because the wire format is OpenAI’s, the
openai Python package talks to it unchanged: point base_url at OpenRouter and
switching from a Google model to a Chinese one is a change to a string.
A plain proxy would give you one endpoint and nothing else. Four things here are worth building around, and the last two are the ones that bite:
- Every response reports what it cost. OpenRouter adds a
costfield to the usage object, in dollars, for the request you just made. You do not have to reconstruct spend from a token count and a price sheet. - A request can carry a fallback chain. List several models and a rate-limited or dead provider fails over to the next one, server-side, inside the same request.
- Free capacity is shared, so 429 is routine. A
:freemodel can return 429 from its upstream provider on a first call of the day with your own quota untouched. One free model measured here 429’d on every bare call, and never once when it led a fallback chain. - Most free models are reasoning models. They spend the bulk of the token
budget on a
reasoningchannel that never appears in the answer. Read onlycontentand a streamed reply can be empty; ignore the reasoning token count and a paid model looks inexplicably expensive.
This article builds a small Python toolkit on macOS that handles all four, using
uv, make, and pytest. It runs end to end on free models, so the whole
tutorial costs nothing beyond signing up.
What you will build
config.py: settings from the environment and a gitignored.env, failing loudly when the key is missing.catalog.py: a query against the public model list that answers “what can I call today, and what does it cost per million tokens?”.client.py: the OpenAI SDK pointed at OpenRouter, returning the answer, the model that actually served it, the reasoning tokens it burned, and the cost.errors.py: a failure ladder that turns each documented status into a line a user can act on.stream.py: streaming that separates reasoning from the answer and refuses to present a provider failure or a truncated reply as a finished one.ask.pyand aMakefilewhose baremakeprints a help screen.- 40
pytesttests that run with no API key and no network.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verify withuv --version. This article was validated on uv 0.11.26. - Xcode Command Line Tools (
xcode-select --install) formake. - An OpenRouter account and API key, created at
openrouter.ai/keys. The free tier needs no card:
below 10 purchased credits you get 20 requests per minute and 50 per day
against
:freemodels, which is enough for this tutorial with room to spare.
Familiarity with the OpenAI chat-completions shape helps but is not required. If you have run a local model behind an OpenAI-compatible endpoint, as in Serve an OpenAI-Compatible Endpoint with llama-server, this is the same interface with someone else’s GPUs behind it.
Step 1: Scaffold the project and ignore secrets first
The .gitignore comes before anything else, because one of the files this
project ends up with holds a live API key. Writing the ignore rules first means
there is no window in which git add -A would sweep up .env.
Create the files
mkdir -p openrouter-quickstart
cd openrouter-quickstart
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
# Secrets — .env holds the real OpenRouter key and must never be committed.
# .env.example is the committed template.
.env
*.env
!.env.example
# macOS
.DS_Store
Detailed breakdown
.envand*.envare ignored,.env.exampleis exempted. The!line has to come after the patterns it overrides, because git applies the last matching rule. It is belt and braces here:*.envmatches names ending in.env, so it never catches.env.example. Add a broader pattern such as.env*later and the exemption is what keeps the committed template from disappearing..venv/stays out becauseuvrecreates it fromuv.lockin seconds.- An OpenRouter key is a bearer token with spending attached to it. If one ever reaches a commit, rotate it at openrouter.ai/keys rather than trying to rewrite history and hoping.
Step 2: Initialize the project with uv
uv init writes the pyproject.toml and pins a Python version. The dependency
list is short: the openai package for the chat API, and python-dotenv to read
the .env file. Nothing else is needed, because openai brings its own HTTP
client along.
Create the files
uv init --name orkit --no-workspace
rm -f main.py hello.py
uv add openai python-dotenv
uv add --dev pytest
Add the code: pyproject.toml
[project]
name = "orkit"
version = "0.1.0"
description = "A small OpenRouter toolkit: catalogue, costed calls, and streaming"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"openai>=3.3.1",
"python-dotenv>=1.2.3",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
Detailed breakdown
openai3.x shipshttpx2, nothttpx. Check what landed:uv pip list | grep -i httpxYou will see
httpx2 2.12.0and nohttpxat all. This matters more than it looks. The injection recipe from every v1-era tutorial (http_client=httpx.Client(...)) now dies at the import, becausehttpxis not installed; the parameter is also typedhttpx2.Client | None, so a type checker rejects the old form even where one is importable. Every module below importshttpx2, and the tests usehttpx2.MockTransport. There is no separate HTTP dependency to add.pythonpath = ["."]lets the tests import the flat modules (config,client) without asrc/layout or an editable install.testpaths = ["tests"]keeps a bareuv run pytestfrom wandering into.venv.
Step 3: Keep the key out of source
Two files carry the configuration: a committed .env.example that documents
every variable, and a gitignored .env holding the real key. The loader in
config.py refuses to build settings without a key, so a missing key surfaces as
one clear sentence at startup rather than a 401 from three frames down.
Create the files
touch .env.example config.py
Add the code: .env.example
# Copy to .env (`make setup` does it) and fill in your key. .env is gitignored.
# Create a key at https://openrouter.ai/keys
OPENROUTER_API_KEY=sk-or-v1-replace-me
# Default model. Free ids end in `:free` and change over time, and a busy free
# model returns 429 from its upstream provider even when your own quota is
# untouched — run `make models` to see what is available right now.
OPENROUTER_MODEL=nvidia/nemotron-3-super-120b-a12b:free
# Tried in order when the model above fails. Free models are heavily shared, so
# a 429 from the upstream provider is routine even on your first call of the
# day; a chain turns that into a slower answer instead of an error. Set this to
# an empty value to disable fallbacks entirely.
OPENROUTER_FALLBACKS=liquid/lfm-2.5-2.6b:free,google/gemma-4-31b-it:free
# Attribution headers. These are optional; they identify your app on
# openrouter.ai's public rankings. Use your real site once you have one.
OPENROUTER_REFERER=https://example.com
OPENROUTER_TITLE=openrouter-quickstart
# Seconds to wait for a response before giving up.
OPENROUTER_TIMEOUT=60
Detailed breakdown
- The default model is a free one, so a reader who copies the template and adds a key can run every command without spending anything.
- Free model ids rotate, and free capacity is shared. Two separate problems live in that sentence. Ids change as vendors add and retire free tiers, and a free model that exists can still return 429 from its upstream provider on your first call of the day, with your own quota untouched. Measured while writing this article, one free model returned 429 on all three bare calls it was sent.
OPENROUTER_FALLBACKSis why the first call works. The chain is tried in order, server-side, in the same request. With it configured, eight consecutive calls succeeded against a model that had 429’d on every bare call moments earlier.- The attribution values are yours to change. They are not credentials.
Add the code: config.py
"""Settings for the OpenRouter client, loaded from the environment.
Values come from real environment variables first, then from a local `.env`
file (which is gitignored). Nothing is hard-coded, and a missing key fails
loudly at startup instead of surfacing as a 401 three layers down.
"""
import os
from dataclasses import dataclass
from dotenv import load_dotenv
BASE_URL = "https://openrouter.ai/api/v1"
# Free model ids rotate, and a busy one 429s from its upstream provider even
# when your own quota is untouched. `make models` lists what is live today.
DEFAULT_MODEL = "nvidia/nemotron-3-super-120b-a12b:free"
DEFAULT_FALLBACKS = ("liquid/lfm-2.5-2.6b:free", "google/gemma-4-31b-it:free")
class ConfigError(RuntimeError):
"""Raised when a required setting is absent."""
@dataclass(frozen=True)
class Settings:
api_key: str
model: str
fallbacks: tuple[str, ...]
referer: str
title: str
timeout: float
def _split(raw: str | None, default: tuple[str, ...]) -> tuple[str, ...]:
"""Parse a comma-separated model list, falling back to `default`.
An explicitly empty value means "no fallbacks", which is different from an
unset variable meaning "use the defaults".
"""
if raw is None:
return default
return tuple(part.strip() for part in raw.split(",") if part.strip())
def load_settings(env: dict[str, str] | None = None) -> Settings:
"""Build Settings from `env`, defaulting to the process environment.
Passing `env` explicitly is what makes this testable: the tests hand in a
plain dict and never touch the developer's real key.
"""
if env is None:
load_dotenv() # no-op when .env is absent; never overrides a real env var
env = dict(os.environ)
api_key = env.get("OPENROUTER_API_KEY", "").strip()
if not api_key:
raise ConfigError(
"OPENROUTER_API_KEY is not set. Copy .env.example to .env and put "
"your key there (get one at https://openrouter.ai/keys)."
)
return Settings(
api_key=api_key,
model=env.get("OPENROUTER_MODEL", DEFAULT_MODEL).strip(),
fallbacks=_split(env.get("OPENROUTER_FALLBACKS"), DEFAULT_FALLBACKS),
referer=env.get("OPENROUTER_REFERER", "https://example.com").strip(),
title=env.get("OPENROUTER_TITLE", "openrouter-quickstart").strip(),
timeout=float(env.get("OPENROUTER_TIMEOUT", "60")),
)
Detailed breakdown
BASE_URLlives here because both the SDK client (Step 5) and the raw streaming request (Step 7) need it, and a single constant keeps them from drifting apart.load_settingstakes an optionalenvdict. Tests pass one in, so they never read the developer’s real environment, never need a key, and cannot accidentally spend money. Production calls it with no argument and gets the process environment.load_dotenv()does not override real environment variables. An exportedOPENROUTER_API_KEYbeats the file, which is what you want in CI.- A blank key is treated as a missing key.
.strip()catches the common case ofOPENROUTER_API_KEY=left in the file, or a trailing space pasted along with the token. _splitdistinguishes unset from empty. An absentOPENROUTER_FALLBACKSmeans “use the defaults”; an explicitly empty one means “no fallbacks”. A plainenv.get(..., default)would collapse those two into one and leave no way to turn fallbacks off.Settingsis frozen. Configuration read once at startup should not be mutable afterwards; a frozen dataclass makes an accidental write aTypeError.
Step 4: Find out what you can actually call
Before sending a single paid token, ask OpenRouter what exists. GET /api/v1/models is public: no key, no auth header, no spend. It answers the two
questions that decide everything downstream, which model ids are valid right now
and what each one costs.
Prices arrive as strings of dollars per token, values like "0.000000044".
That is unreadable at a glance and invites a misplaced decimal, so this module
converts to dollars per million tokens, the unit every vendor’s pricing page
uses.
Create the file
touch catalog.py
Add the code: catalog.py
"""Query the OpenRouter model catalogue.
`GET /api/v1/models` is public: it needs no API key, which makes it the
cheapest way to answer "what can I actually call today, and what does it
cost?". Prices come back as USD *per token* in string form, so they are
converted to dollars per million tokens for display.
Run it directly:
uv run python catalog.py # free models, widest context first
uv run python catalog.py --all # every model
uv run python catalog.py --tools # only models that support tool calling
"""
import argparse
from dataclasses import dataclass
import httpx2
from config import BASE_URL
@dataclass(frozen=True)
class Model:
id: str
name: str
context_length: int
prompt_usd_per_mtok: float
completion_usd_per_mtok: float
supported_parameters: tuple[str, ...]
@property
def is_free(self) -> bool:
return self.id.endswith(":free")
@property
def supports_tools(self) -> bool:
return "tools" in self.supported_parameters
def parse_models(payload: dict) -> list[Model]:
"""Turn the raw /models JSON into Model records.
Kept separate from the HTTP call so the tests can feed it a fixture.
"""
models: list[Model] = []
for item in payload.get("data", []):
pricing = item.get("pricing") or {}
models.append(
Model(
id=item["id"],
name=item.get("name", item["id"]),
context_length=item.get("context_length") or 0,
prompt_usd_per_mtok=float(pricing.get("prompt") or 0.0) * 1_000_000,
completion_usd_per_mtok=float(pricing.get("completion") or 0.0)
* 1_000_000,
supported_parameters=tuple(item.get("supported_parameters") or ()),
)
)
return models
def fetch_models(client: httpx2.Client | None = None) -> list[Model]:
"""Fetch the live catalogue. No Authorization header is required."""
owns_client = client is None
client = client or httpx2.Client(timeout=30.0)
try:
response = client.get(f"{BASE_URL}/models")
response.raise_for_status()
return parse_models(response.json())
finally:
if owns_client:
client.close()
def format_table(models: list[Model]) -> str:
rows = [f"{'MODEL':<44} {'CONTEXT':>9} {'$/MTOK IN':>10} {'$/MTOK OUT':>10} TOOLS"]
for m in models:
rows.append(
f"{m.id:<44} {m.context_length:>9,} "
f"{m.prompt_usd_per_mtok:>10.4f} {m.completion_usd_per_mtok:>10.4f} "
f"{'yes' if m.supports_tools else 'no'}"
)
return "\n".join(rows)
def main() -> None:
parser = argparse.ArgumentParser(description="List OpenRouter models.")
parser.add_argument("--all", action="store_true", help="include paid models")
parser.add_argument(
"--tools", action="store_true", help="only models supporting tool calling"
)
args = parser.parse_args()
models = fetch_models()
if not args.all:
models = [m for m in models if m.is_free]
if args.tools:
models = [m for m in models if m.supports_tools]
models.sort(key=lambda m: m.context_length, reverse=True)
print(format_table(models))
print(f"\n{len(models)} model(s).")
if __name__ == "__main__":
main()
Detailed breakdown
parse_modelsis split fromfetch_modelsso the parsing rules can be tested against a fixture with no network at all. The HTTP function is then thin enough to verify with one mock-transport test.- Every optional field is defensively read.
item.get("pricing") or {}handles both a missing key and an explicitnull; the same pattern guardscontext_lengthandsupported_parameters. Entries in a 400-model catalogue are not uniform, and one sparse record should not crash the listing. is_freekeys off the:freesuffix, which is how OpenRouter marks a free variant. The same underlying model often appears twice, once paid and once free with tighter rate limits.supports_toolsreadssupported_parameters. If you intend to do tool calling, filter on this rather than assuming: plenty of models in the catalogue accepttemperatureand nothing more.fetch_modelsaccepts an injected client and only closes what it created. Passing a client in is how the test drives it throughMockTransport; passing nothing gives the CLI a sensible 30-second timeout.- Sorting by context descending puts the models worth using at the top. For free models that ordering tracks recency more than quality, since the widest context windows tend to belong to the newest releases.
Run it
uv run python catalog.py
The free list on the day of writing, trimmed to the first ten rows:
MODEL CONTEXT $/MTOK IN $/MTOK OUT TOOLS
nvidia/nemotron-3.5-lightning:free 1,000,000 0.0000 0.0000 yes
nvidia/nemotron-3-ultra-550b-a55b:free 1,000,000 0.0000 0.0000 yes
dots-studio/dots-3-note-preview:free 512,000 0.0000 0.0000 yes
poolside/laguna-s-2.1:free 262,144 0.0000 0.0000 yes
poolside/laguna-xs-2.1:free 262,144 0.0000 0.0000 yes
google/gemma-4-26b-a4b-it:free 262,144 0.0000 0.0000 yes
google/gemma-4-31b-it:free 262,144 0.0000 0.0000 yes
nvidia/nemotron-3-super-120b-a12b:free 262,144 0.0000 0.0000 yes
cohere/north-mini-code:free 256,000 0.0000 0.0000 yes
z-ai/glm-5.2:free 256,000 0.0000 0.0000 yes
Your list will differ. Pick an id from your own run and put it in .env as
OPENROUTER_MODEL. uv run python catalog.py --all prints the whole catalogue,
which is where the price columns start earning their keep.
Step 5: Make the call, and find out what it cost
This is the module that does the actual work, and it is short, because
OpenRouter speaks OpenAI’s dialect. Constructing the client is the standard
OpenAI(...) call with base_url redirected. Two details are OpenRouter’s own:
the attribution headers, and a cost field that the OpenAI SDK does not know
exists.
That second one is the interesting part. usage.cost is not in the OpenAI
schema, so the SDK’s typed CompletionUsage model has no field for it. Pydantic
keeps unknown keys in the model’s extras rather than discarding them, so the
value survives, but reading it takes a deliberate line of code. Ignore it and you
have thrown away the one number that told you what the request cost.
Create the file
touch client.py
Add the code: client.py
"""An OpenRouter client built on the OpenAI SDK, plus cost-aware completions.
OpenRouter speaks the OpenAI chat-completions wire format, so the `openai`
package works unchanged once its `base_url` points at OpenRouter. Two things
are OpenRouter-specific and are handled here: the attribution headers, and
reading the per-request `cost` that OpenRouter adds to the `usage` object.
"""
from dataclasses import dataclass
import httpx2
from openai import OpenAI
from config import BASE_URL, Settings
@dataclass(frozen=True)
class Completion:
"""One finished call, with what it cost and who actually served it."""
text: str
model: str
prompt_tokens: int
completion_tokens: int
reasoning_tokens: int
cost_usd: float | None
finish_reason: str | None
@property
def was_truncated(self) -> bool:
"""True when the token ceiling cut the answer short."""
return self.finish_reason == "length"
@property
def cost_note(self) -> str:
if self.cost_usd is None:
return "cost: not reported"
return f"cost: ${self.cost_usd:.6f}"
def build_client(
settings: Settings, http_client: httpx2.Client | None = None
) -> OpenAI:
"""Point the OpenAI SDK at OpenRouter.
`http_client` exists for the tests, which pass an `httpx2.Client` wrapping a
`MockTransport` so no request leaves the machine.
"""
return OpenAI(
base_url=BASE_URL,
api_key=settings.api_key,
timeout=settings.timeout,
max_retries=0,
default_headers={
"HTTP-Referer": settings.referer,
"X-OpenRouter-Title": settings.title,
},
http_client=http_client,
)
def read_reasoning_tokens(usage) -> int:
"""Count the tokens a reasoning model spent thinking.
They are billed and they count against `max_tokens`, but they are not in
the answer, so a large number here with a short answer explains both a
surprising bill and a truncated reply.
"""
details = getattr(usage, "completion_tokens_details", None)
if details is None:
return 0
return int(getattr(details, "reasoning_tokens", 0) or 0)
def read_cost(usage) -> float | None:
"""Pull OpenRouter's `cost` off the SDK's typed usage object.
`cost` is not part of the OpenAI schema, so the SDK parks it in the
pydantic model's extras rather than a declared field. It is a float of
USD charged for this request.
"""
if usage is None:
return None
extra = getattr(usage, "model_extra", None) or {}
cost = extra.get("cost", getattr(usage, "cost", None))
return float(cost) if cost is not None else None
def complete(
client: OpenAI,
prompt: str,
model: str,
fallbacks: list[str] | None = None,
max_tokens: int = 2048,
) -> Completion:
"""Send one chat completion, optionally with a fallback chain.
`models` + `route: "fallback"` are OpenRouter extensions to the request
body, so they travel through `extra_body`, which the SDK merges into the
JSON it posts.
"""
extra_body: dict[str, object] = {}
if fallbacks:
extra_body["models"] = [model, *fallbacks]
extra_body["route"] = "fallback"
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
extra_body=extra_body or None,
)
usage = response.usage
choice = response.choices[0]
return Completion(
text=(choice.message.content or "").strip(),
model=response.model,
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
reasoning_tokens=read_reasoning_tokens(usage),
cost_usd=read_cost(usage),
finish_reason=choice.finish_reason,
)
Detailed breakdown
base_urlis the whole integration. Everything else about the SDK stays as it is documented for OpenAI.client.chat.completions.createremains supported indefinitely inopenai3.x even thoughresponses.createis now the SDK’s headline API, and chat completions is the surface OpenRouter documents, so that is what this uses.max_retries=0is deliberate. The SDK retries some failures by default, which quietly turns one 429 into several against a free tier that allows 20 requests a minute. Fallback routing (below) is the better answer to a failed request, and it happens server-side in a single call.- The attribution headers are optional.
HTTP-RefererandX-OpenRouter-Titleput your app on OpenRouter’s public rankings; omit them and calls still work.X-Titleis the older spelling and is still accepted, so existing code does not break. read_costlooks inmodel_extrafirst, then falls back togetattr. Both routes work with the current SDK; checkingmodel_extrafirst states the intent, which is thatcostis an extension field rather than something the SDK promises. ANoneresult is reported as unknown rather than as zero, because “free” and “not reported” are different facts.fallbacksbecomemodelsplusroute: "fallback". The primary model is first in the list, so the array is the full preference order. OpenRouter tries them in turn and only fails when every entry fails. These are OpenRouter extensions to the request body, which is why they go throughextra_bodyinstead of a named SDK parameter; the SDK merges that dict into the JSON it posts. OpenRouter’s current documentation describes themodelsarray on its own and no longer mentionsroute, which is still accepted; if you are trimming the body, that is the field to drop.response.modelis read back, not assumed. With a fallback chain the model that answered may not be the one you asked for, and this is how you find out. The same applies to theopenrouter/autorouter, which picks on your behalf.extra_body or Noneavoids sending an empty object when there are no fallbacks, keeping the request body identical to a plain OpenAI call.reasoning_tokensare billed, invisible, and count againstmax_tokens. A real call to a free model in Step 12 reported 36 reasoning tokens against 37 output ones for a two-character answer. Without this number that model looks inexplicably expensive for the text it produced. OpenRouter counts reasoning as output tokens, but the two figures do not always reconcile, so read them as a scale rather than subtracting one from the other.finish_reasonis carried, not discarded."length"means the ceiling cut the answer off, which on a reasoning model can happen before any answer text exists at all.was_truncatedgives callers one obvious thing to check.max_tokensdefaults to 2048. The usual 512 is routinely consumed by reasoning alone on the free models this article uses.
Step 6: Give every failure a message someone can act on
The OpenAI SDK raises a named exception per status code, but only for the codes OpenAI itself returns. OpenRouter adds meanings of its own, and the two that matter most are the two the SDK handles worst.
402 has no named class. Running out of credits is the single most common
OpenRouter failure, and it lands as a bare APIStatusError. A handler that
catches AuthenticationError and RateLimitError and calls it thorough will
show a traceback for the one failure your users are most likely to hit. 503 is
not a generic outage either: it means no provider met your routing
requirements, because your constraints ruled them all out or because every
eligible one was overloaded. Both 502 and 503 arrive as InternalServerError,
so telling them apart requires reading status_code.
One more shape has to go through the same ladder. The streaming module in Step 7
posts with httpx2 rather than the SDK, so a 401 there raises
httpx2.HTTPStatusError, which no openai exception check matches. Left
unhandled, --stream reports a raw exception repr for the same failure that
make ask explains in a sentence.
Create the file
touch errors.py
Add the code: errors.py
"""Turn OpenRouter failures into one clear line for a human.
The OpenAI SDK raises a named exception for the status codes OpenAI itself
returns. OpenRouter adds meanings of its own, and the one users hit most --
402, out of credits -- has no named class at all, so a handler that only
catches `AuthenticationError` and `RateLimitError` misses it and shows a
traceback instead.
`stream.py` posts with `httpx2` rather than the SDK, so its failures arrive as
`httpx2` exceptions. Both shapes are normalized here so there is one ladder
rather than two that drift apart.
"""
import httpx2
import openai
def describe_failure(exc: Exception) -> str:
"""Map an exception from a completion call to an actionable message."""
if isinstance(exc, (openai.APIConnectionError, httpx2.TransportError)):
return "Could not reach openrouter.ai. Check your network, then retry."
if isinstance(exc, httpx2.HTTPStatusError):
exc = _as_status_error(exc)
if isinstance(exc, openai.APIStatusError):
detail = _detail(exc)
match exc.status_code:
case 401:
return f"Key rejected (401). Check OPENROUTER_API_KEY. {detail}"
case 402:
return (
"Out of credits (402). Add credits at "
f"https://openrouter.ai/credits, or use a `:free` model. {detail}"
)
case 429:
return (
"Rate limited (429). Free models allow 20 requests/minute and "
f"50/day below 10 purchased credits. {detail}"
)
case 502:
return f"Upstream model failed (502). Try a fallback model. {detail}"
case 503:
return (
"No provider matched your routing requirements (503). Relax "
"the `provider` constraints, allow fallbacks, or retry if "
f"every eligible provider was overloaded. {detail}"
)
case _:
return f"OpenRouter returned {exc.status_code}. {detail}"
return f"Unexpected failure: {exc!r}"
def _as_status_error(exc: httpx2.HTTPStatusError) -> openai.APIStatusError:
"""Rewrap a raw streaming failure so it runs through the same ladder.
`raise_for_status()` on the streamed response raises `HTTPStatusError`,
whose body still carries the `{"error": {...}}` envelope the SDK would
have unwrapped, so unwrap it here.
"""
try:
payload = exc.response.json()
except (ValueError, httpx2.StreamError):
payload = {}
inner = payload.get("error") if isinstance(payload, dict) else None
return openai.APIStatusError(
str(exc), response=exc.response, body=inner if isinstance(inner, dict) else {}
)
def _detail(exc: openai.APIStatusError) -> str:
"""Extract OpenRouter's message.
The SDK unwraps the `{"error": {...}}` envelope, so `exc.body` is already
the inner object carrying `code` and `message`.
"""
body = exc.body
if isinstance(body, dict):
message = body.get("message")
if message:
return f"Server said: {message}"
return ""
Detailed breakdown
- The ladder dispatches on
status_code, not on exception class. That is what lets 402 and 503 get real messages despite having no dedicated class, and it keeps onematchstatement as the single place the mapping lives. APIConnectionErroris checked first because it is not anAPIStatusError. There is no response and no status code to read: the request never arrived.httpx2.TransportErroris its raw-request twin, thrown when the streaming call in Step 7 cannot connect._as_status_errorrewraps a streaming failure into the SDK’s shape. A 4xx fromstream.pyis anhttpx2.HTTPStatusError, and converting it once here beats maintaining a second ladder that drifts out of step with this one. The raw body still has the{"error": {...}}envelope around it, which is why the rewrap digs one level in where_detaildoes not.exc.bodyis already unwrapped. OpenRouter sends{"error": {"code": 402, "message": "..."}}, and the SDK hands you the inner object. Reaching forexc.body["error"]["message"]returns nothing.- Each message names the next action, not just the condition. Out of credits points at the credits page and at the free tier; 503 points at the routing constraints and, failing those, at a retry. A message that only restates the status code makes the reader do the lookup you already did.
- The final
returnkeeps unknown exceptions visible. Swallowing them into a friendly string would hide bugs in your own code, so anything unrecognized is reported with itsrepr.
Step 7: Stream, and catch the two failures that arrive as HTTP 200
Streaming adds failure modes that no status-code ladder can catch. Once the response headers are sent, the HTTP status is locked at 200, so nothing that goes wrong afterwards can come back as a 4xx or 5xx.
The first is a provider dying mid-generation. It arrives as an ordinary event in
the stream carrying an error object and finish_reason: "error". Code that
loops over chunks appending delta.content handles that exactly as if it were a
normal end of stream, and the user sees a confident half-sentence.
The second is more common and cost me a working draft of this article. Most
free models on OpenRouter today are reasoning models. They emit their thinking
on delta.reasoning and the answer on delta.content, and the max_tokens
budget covers both. A reader who streams a haiku prompt at a small reasoning
model can watch it spend every token thinking and finish with
finish_reason: "length" and an empty answer. A parser that only reads
delta.content prints nothing at all and exits successfully, which looks like a
bug in your code rather than a truncated response.
Neither reasoning nor the error event is part of the OpenAI schema, so the raw
httpx2 request here is deliberate rather than a shortcut. Reading the JSON
directly is the honest way to see both.
Create the file
touch stream.py
Add the code: stream.py
"""Stream tokens, and catch the two failures that arrive with HTTP 200.
Once the response headers are sent the status is locked at 200, so neither of
these can come back as a 4xx or 5xx:
1. A provider dying mid-generation arrives as an ordinary SSE event carrying an
`error` object and `finish_reason: "error"`. Code that only concatenates
deltas treats it as a clean, short answer.
2. A reasoning model spending its whole token budget on `delta.reasoning`
before it writes a single character of `delta.content` ends with
`finish_reason: "length"` and an empty answer. Most free models on
OpenRouter today are reasoning models, so this is the common case, not the
exotic one.
The raw `httpx2` request here (rather than the SDK's streaming helper) keeps
both visible: neither `reasoning` nor the error event is part of the OpenAI
schema, so reading the JSON directly is the honest way to see them.
"""
import json
from collections.abc import Iterator
from dataclasses import dataclass
import httpx2
from config import BASE_URL, Settings
CONTENT = "content"
REASONING = "reasoning"
class StreamInterrupted(RuntimeError):
"""The stream ended with an error event rather than a normal stop."""
class StreamTruncated(RuntimeError):
"""The stream hit the token ceiling before the model finished."""
@dataclass(frozen=True)
class Delta:
"""One piece of streamed text, and which channel it came from."""
kind: str # CONTENT or REASONING
text: str
def stream_completion(
settings: Settings,
prompt: str,
model: str,
http_client: httpx2.Client | None = None,
max_tokens: int = 2048,
) -> Iterator[Delta]:
"""Yield Deltas, raising on a mid-stream error or a truncated answer."""
owns_client = http_client is None
client = http_client or httpx2.Client(timeout=settings.timeout)
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"stream": True,
}
headers = {
"Authorization": f"Bearer {settings.api_key}",
"HTTP-Referer": settings.referer,
"X-OpenRouter-Title": settings.title,
"Content-Type": "application/json",
}
finish_reason: str | None = None
try:
with client.stream(
"POST", f"{BASE_URL}/chat/completions", json=payload, headers=headers
) as response:
if response.status_code >= 400:
# A streamed body is not read yet, and the error JSON is the
# only place OpenRouter says what went wrong.
response.read()
response.raise_for_status()
for line in response.iter_lines():
if not line.startswith("data: "):
continue # SSE comments (": OPENROUTER PROCESSING") and blanks
data = line.removeprefix("data: ")
if data == "[DONE]":
break
event = json.loads(data)
finish_reason = _finish_reason(event) or finish_reason
yield from _deltas(event)
finally:
if owns_client:
client.close()
if finish_reason == "length":
raise StreamTruncated(
f"hit the {max_tokens}-token ceiling before finishing. On a "
"reasoning model the budget covers reasoning tokens too, so raise "
"max_tokens."
)
def _finish_reason(event: dict) -> str | None:
return ((event.get("choices") or [{}])[0]).get("finish_reason")
def _deltas(event: dict) -> Iterator[Delta]:
"""Yield the text on one SSE event, or raise if it is an error event."""
error = event.get("error")
choice = (event.get("choices") or [{}])[0]
if error or choice.get("finish_reason") == "error":
code = (error or {}).get("code", "unknown")
message = (error or {}).get("message", "provider failed mid-stream")
raise StreamInterrupted(f"[{code}] {message}")
delta = choice.get("delta") or {}
# `reasoning` first: a reasoning model emits it before any answer text.
if reasoning := delta.get("reasoning"):
yield Delta(REASONING, reasoning)
if content := delta.get("content"):
yield Delta(CONTENT, content)
Detailed breakdown
Deltacarries a channel, not just text. Yielding bare strings forces the caller to guess whether it is holding reasoning or an answer, and merging the two into one stream produces output that reads like the model talking to itself. Keeping them apart letsask.pyprint the answer to stdout and the thinking to stderr.- Reasoning is yielded before content within a single event, because that is the order the model produced them in when both appear on the same delta.
- Two exceptions, two meanings.
StreamInterruptedsays the provider broke;StreamTruncatedsays you hit the ceiling. Both leave the text already yielded valid, and neither should be presented as a finished answer. - The truncation check runs after the loop, so every delta is delivered first. The exception surfaces at the end of iteration, which is the moment the caller learns the answer is incomplete.
max_tokensdefaults to 2048, not 512. On a reasoning model, 512 is routinely consumed by thinking alone. This default was raised after a small free model burned the entire budget reasoning about a haiku and returned nothing.- Non-
data:lines are skipped. OpenRouter sends SSE comments such as: OPENROUTER PROCESSINGto hold the connection open while a provider warms up, and blank lines between events. How many comments arrive depends on how long the provider takes to start: a run measured here got two before the first token of content, and a cold provider can send far more. - The error body is read before
raise_for_status(). On a streamed response the body has not been fetched when the status arrives, so touching it later raisesResponseNotReadand the reader sees that instead of the 401 underneath it. Reading it on a 4xx first is what letserrors.pyquote what OpenRouter said. AMockTransportholds its body in memory and cannot reproduce this, so it is a live-run finding rather than a test one. [DONE]breaks rather than returns, so the truncation check after the loop still runs. An earlyreturnwould skip it, which is a quiet way to reintroduce the empty-answer bug.owns_clientkeeps ownership straight. An injected client belongs to the caller and must not be closed here.
Step 8: Wire it to a command line
ask.py is the thin layer that turns the modules into something you can run:
resolve settings, pick streaming or not, and translate any exception through the
failure ladder. It writes the answer to stdout and everything else to stderr, so
uv run python ask.py "..." > answer.txt captures the text alone while the cost
line, and the model’s reasoning if you asked for it, still reach your terminal.
Create the file
touch ask.py
Add the code: ask.py
"""Command line entry point: ask a question, print the answer and its cost.
uv run python ask.py "Name three uses for a paperclip."
uv run python ask.py --stream "Write a haiku about port 8080."
uv run python ask.py --stream --show-reasoning "Why is the sky blue?"
uv run python ask.py --model z-ai/glm-5.2:free "Same question, other model."
"""
import argparse
import sys
from client import build_client, complete
from config import ConfigError, load_settings
from errors import describe_failure
from stream import CONTENT, StreamInterrupted, StreamTruncated, stream_completion
def main() -> int:
parser = argparse.ArgumentParser(description="Ask a model via OpenRouter.")
parser.add_argument("prompt", help="the question to send")
parser.add_argument("--model", help="override OPENROUTER_MODEL")
parser.add_argument(
"--fallback",
action="append",
default=[],
help="model to try if the primary fails (repeatable); "
"overrides OPENROUTER_FALLBACKS",
)
parser.add_argument("--stream", action="store_true", help="stream the answer")
parser.add_argument(
"--show-reasoning",
action="store_true",
help="with --stream, also print the model's reasoning to stderr",
)
args = parser.parse_args()
try:
settings = load_settings()
except ConfigError as exc:
print(exc, file=sys.stderr)
return 2
model = args.model or settings.model
fallbacks = args.fallback or list(settings.fallbacks)
if args.stream:
try:
for delta in stream_completion(settings, args.prompt, model):
if delta.kind == CONTENT:
print(delta.text, end="", flush=True)
elif args.show_reasoning:
print(delta.text, end="", file=sys.stderr, flush=True)
print()
except StreamInterrupted as exc:
print(f"\nstream failed: {exc}", file=sys.stderr)
return 1
except StreamTruncated as exc:
print(f"\nanswer incomplete: {exc}", file=sys.stderr)
return 1
except Exception as exc: # noqa: BLE001 - mapped to a human message below
print(f"\n{describe_failure(exc)}", file=sys.stderr)
return 1
return 0
client = build_client(settings)
try:
result = complete(client, args.prompt, model, fallbacks=fallbacks)
except Exception as exc: # noqa: BLE001 - mapped to a human message below
print(describe_failure(exc), file=sys.stderr)
return 1
print(result.text)
reasoning = (
f" ({result.reasoning_tokens} reasoning)" if result.reasoning_tokens else ""
)
print(
f"\n-- served by {result.model} | "
f"{result.prompt_tokens} in / {result.completion_tokens} out{reasoning} | "
f"{result.cost_note}",
file=sys.stderr,
)
if result.was_truncated:
print(
"-- warning: hit the token ceiling; raise max_tokens for a full answer",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Detailed breakdown
- Three exit codes carry meaning:
0success,1the call failed or the answer is incomplete,2the configuration is wrong. A shell script can act on the difference between “fix your.env” and “the provider is down”. - A truncated answer exits 1. The text is printed, because it is real, but the exit code and the warning say it is not the whole answer. Treating that as success is how a pipeline ends up with silently half-finished output.
--fallbackoverridesOPENROUTER_FALLBACKS, and the configured chain is the default. Free models return 429 from their upstream provider often enough that a bare call is the fragile path; the chain is what makes the firstmake askof the day work.- The chain applies to
make ask, not tomake stream.stream_completionposts a plain body with onemodelin it, so--fallbackandOPENROUTER_FALLBACKSare both ignored on the streaming path. A streamed call against a busy free model is the bare call, 429s and all. - The two channels go to different streams. Answer text on stdout, reasoning
on stderr and only with
--show-reasoning. Reasoning is usually several times longer than the answer, so printing it by default would bury the result. StreamInterruptedandStreamTruncatedare caught before the generic handler, because both need the opposite framing from a failed request: the text already printed is valid, and only the ending is missing.- The broad
except Exceptionis intentional and narrow in effect. Every exception goes todescribe_failure, which reports unrecognized errors with theirreprrather than swallowing them.
Step 9: A Makefile whose bare make explains itself
Running make with no argument should tell you what the project can do. The help
target parses the ## comments out of the Makefile itself, so a target added
without a comment is invisible in help, which is a small forcing function toward
documenting each one.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help setup models models-all models-tools ask stream test key clean
Q ?= Name three uses for a paperclip.
help: ## Show this help screen
@echo "OpenRouter quickstart — available targets:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-14s %s\n", $$1, $$2}'
@echo ""
@echo "Override the prompt with Q=..., e.g. make ask Q='Why is the sky blue?'"
setup: ## Install dependencies and create .env from the template
uv sync
@test -f .env || (cp .env.example .env && echo "created .env — add your key")
models: ## List free models, widest context first
uv run python catalog.py
models-all: ## List every model with per-million-token prices
uv run python catalog.py --all
models-tools: ## List free models that support tool calling
uv run python catalog.py --tools
ask: ## Ask one question (make ask Q='...')
uv run python ask.py "$(Q)"
stream: ## Ask one question and stream the answer (make stream Q='...')
uv run python ask.py --stream "$(Q)"
test: ## Run the test suite (no network, no API key)
uv run pytest -v
key: ## Show the credit and rate-limit status of your API key
@set -a && . ./.env && set +a && \
curl -s https://openrouter.ai/api/v1/key \
-H "Authorization: Bearer $$OPENROUTER_API_KEY" | uv run python -m json.tool
clean: ## Remove caches and build artifacts
rm -rf .pytest_cache __pycache__ tests/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the help screen. Relying onhelpbeing the first target works too, but breaks the moment someone adds a target above it.Q ?= ...gives the prompt a default thatmake ask Q='...'overrides. The?=form means an exportedQin your shell also wins.setupnever clobbers an existing.env. Thetest -fguard is what stops a re-run from overwriting a working key with the template placeholder.keysources.envin the recipe’s own shell.set -aexports what the file defines socurlcan read it, and the$$escapes the dollar sign for make. This is the target to reach for when you get a 429 and want to know whether it is the per-minute or the daily limit.testneeds no key and no network, which is what makes it usable in CI on a fork with no secrets.
Run it
make
OpenRouter quickstart — available targets:
help Show this help screen
setup Install dependencies and create .env from the template
models List free models, widest context first
models-all List every model with per-million-token prices
models-tools List free models that support tool calling
ask Ask one question (make ask Q='...')
stream Ask one question and stream the answer (make stream Q='...')
test Run the test suite (no network, no API key)
key Show the credit and rate-limit status of your API key
clean Remove caches and build artifacts
Override the prompt with Q=..., e.g. make ask Q='Why is the sky blue?'
Step 10: Test the parts that never touch the network
Configuration and parsing are pure functions over data, so most of what follows needs no mocking at all; only the one test that exercises the HTTP call does. Both test files below cover the behaviour that protects you: settings that refuse to load without a key, and a price conversion that has to survive a catalogue entry with missing fields.
Create the files
mkdir -p tests
touch tests/test_config.py tests/test_catalog.py
Add the code: tests/test_config.py
"""Settings tests. Every case passes an explicit env dict, so the developer's
real key is never read."""
import pytest
from config import ConfigError, load_settings
def test_defaults_apply_when_only_the_key_is_set():
settings = load_settings({"OPENROUTER_API_KEY": "sk-or-v1-abc"})
assert settings.api_key == "sk-or-v1-abc"
assert settings.model == "nvidia/nemotron-3-super-120b-a12b:free"
assert settings.fallbacks == (
"liquid/lfm-2.5-2.6b:free",
"google/gemma-4-31b-it:free",
)
assert settings.timeout == 60.0
def test_environment_overrides_every_default():
settings = load_settings(
{
"OPENROUTER_API_KEY": "sk-or-v1-abc",
"OPENROUTER_MODEL": "z-ai/glm-5.2:free",
"OPENROUTER_REFERER": "https://scriptable.com",
"OPENROUTER_TITLE": "my-app",
"OPENROUTER_TIMEOUT": "12.5",
"OPENROUTER_FALLBACKS": "a/one:free, b/two:free",
}
)
assert settings.model == "z-ai/glm-5.2:free"
assert settings.referer == "https://scriptable.com"
assert settings.title == "my-app"
assert settings.timeout == 12.5
assert settings.fallbacks == ("a/one:free", "b/two:free")
def test_an_empty_fallback_list_disables_fallbacks():
"""Unset means "use the defaults"; explicitly empty means "none"."""
settings = load_settings(
{"OPENROUTER_API_KEY": "sk-or-v1-abc", "OPENROUTER_FALLBACKS": ""}
)
assert settings.fallbacks == ()
@pytest.mark.parametrize(
"env", [{}, {"OPENROUTER_API_KEY": ""}, {"OPENROUTER_API_KEY": " "}]
)
def test_a_missing_or_blank_key_fails_immediately(env):
with pytest.raises(ConfigError, match="OPENROUTER_API_KEY is not set"):
load_settings(env)
Detailed breakdown
- Every test passes an explicit dict. No test reads the real environment, so the suite behaves identically on a laptop with a key and in CI without one.
- The parametrized case covers the three shapes of “no key”: absent, empty,
and whitespace. The third is the one that actually happens, from a pasted
token with a trailing space or a
KEY=line left in the template. pytest.raises(..., match=...)asserts the message, not just the type. The message is the feature here; aConfigErrorthat said nothing useful would pass a type-only assertion.
Add the code: tests/test_catalog.py
"""Catalogue tests: price conversion and filtering, against a fixture that
mirrors the real /models payload."""
import httpx2
import pytest
from catalog import fetch_models, parse_models
PAYLOAD = {
"data": [
{
"id": "vendor/cheap-model",
"name": "Vendor: Cheap Model",
"context_length": 8192,
"pricing": {"prompt": "0.000000044", "completion": "0.000000177"},
"supported_parameters": ["max_tokens", "temperature"],
},
{
"id": "vendor/free-model:free",
"name": "Vendor: Free Model",
"context_length": 262144,
"pricing": {"prompt": "0", "completion": "0"},
"supported_parameters": ["tools", "temperature"],
},
{
"id": "vendor/sparse-model",
"name": "Vendor: Sparse Model",
"context_length": None,
"pricing": {},
"supported_parameters": None,
},
]
}
def test_prices_convert_from_per_token_to_per_million():
cheap = parse_models(PAYLOAD)[0]
# approx, not ==: multiplying a tiny per-token float by 1e6 lands at
# 0.17700000000000002, which is right to every digit that matters.
assert cheap.prompt_usd_per_mtok == pytest.approx(0.044)
assert cheap.completion_usd_per_mtok == pytest.approx(0.177)
def test_free_and_tool_support_are_derived_from_the_payload():
_, free, _ = parse_models(PAYLOAD)
assert free.is_free is True
assert free.supports_tools is True
def test_paid_model_is_not_free_even_when_cheap():
cheap = parse_models(PAYLOAD)[0]
assert cheap.is_free is False
assert cheap.supports_tools is False
def test_missing_fields_do_not_raise():
sparse = parse_models(PAYLOAD)[2]
assert sparse.context_length == 0
assert sparse.prompt_usd_per_mtok == 0.0
assert sparse.supported_parameters == ()
def test_fetch_models_calls_the_public_models_endpoint():
seen = {}
def handler(request: httpx2.Request) -> httpx2.Response:
seen["url"] = str(request.url)
seen["auth"] = request.headers.get("authorization")
return httpx2.Response(200, json=PAYLOAD)
client = httpx2.Client(transport=httpx2.MockTransport(handler))
models = fetch_models(client=client)
assert seen["url"] == "https://openrouter.ai/api/v1/models"
assert seen["auth"] is None # the catalogue needs no API key
assert [m.id for m in models] == [
"vendor/cheap-model",
"vendor/free-model:free",
"vendor/sparse-model",
]
Detailed breakdown
- The fixture is shaped like the real payload, including the string prices
and the
nullfields. A fixture with tidy values would pass while the real catalogue crashed the parser. pytest.approxis required, not stylistic.float("0.000000177") * 1_000_000is0.17700000000000002. An==assertion here fails, and “rounding the number to make the test pass” would be the wrong fix: the value is correct to every digit that affects a bill.test_missing_fields_do_not_raiseis the sparse-record guard. It is the test that would have caught aNone * 1_000_000crash before it reached a reader runningmake modelson a day when some vendor shipped an incomplete entry.- The transport test asserts no
Authorizationheader is sent. The catalogue is public, and a key on that request would be pointless exposure.
Step 11: Test the call itself with a mock transport
The remaining tests drive real code paths through httpx2.MockTransport, so
requests are inspected and answered in-process. Nothing reaches the network, no
key is needed, and no spend is possible, yet the assertions cover the parts
easiest to get wrong: the headers, the fallback fields, cost extraction, the
status ladder, and the mid-stream error.
This is where the httpx2 change in openai 3.x becomes concrete. The
MockTransport you inject comes from httpx2, because that is the package
openai 3.x installs; import httpx fails outright on a fresh uv sync.
Create the files
touch tests/test_client.py tests/test_errors.py tests/test_stream.py
Add the code: tests/test_client.py
"""Client tests. Every request is answered by a MockTransport, so no key and
no network are involved."""
import json
import httpx2
import pytest
from openai import AuthenticationError
from client import build_client, complete, read_cost, read_reasoning_tokens
from config import Settings
SETTINGS = Settings(
api_key="test-key",
model="nvidia/nemotron-3-super-120b-a12b:free",
fallbacks=(),
referer="https://example.com",
title="orkit-tests",
timeout=5.0,
)
def completion_body(
model: str = "nvidia/nemotron-3-super-120b-a12b:free",
cost: float = 0.000123,
finish_reason: str = "stop",
reasoning_tokens: int = 0,
):
return {
"id": "gen-test",
"object": "chat.completion",
"created": 1,
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": " a paperclip \n"},
"finish_reason": finish_reason,
}
],
"usage": {
"prompt_tokens": 11,
"completion_tokens": 3,
"total_tokens": 14,
"cost": cost,
"completion_tokens_details": {"reasoning_tokens": reasoning_tokens},
},
}
def client_returning(body: dict, status: int = 200, captured: dict | None = None):
def handler(request: httpx2.Request) -> httpx2.Response:
if captured is not None:
captured["headers"] = dict(request.headers)
captured["body"] = json.loads(request.content)
return httpx2.Response(status, json=body)
transport = httpx2.MockTransport(handler)
return build_client(SETTINGS, http_client=httpx2.Client(transport=transport))
def test_completion_text_is_stripped_and_usage_is_reported():
result = complete(client_returning(completion_body()), "why", SETTINGS.model)
assert result.text == "a paperclip"
assert (result.prompt_tokens, result.completion_tokens) == (11, 3)
assert result.cost_usd == pytest.approx(0.000123)
assert result.cost_note == "cost: $0.000123"
def test_attribution_headers_and_auth_are_sent():
captured: dict = {}
complete(
client_returning(completion_body(), captured=captured), "why", SETTINGS.model
)
headers = captured["headers"]
assert headers["authorization"] == "Bearer test-key"
assert headers["http-referer"] == "https://example.com"
assert headers["x-openrouter-title"] == "orkit-tests"
def test_fallbacks_become_a_models_array_and_fallback_route():
captured: dict = {}
complete(
client_returning(completion_body(), captured=captured),
"why",
"primary/model",
fallbacks=["backup/one", "backup/two"],
)
body = captured["body"]
assert body["model"] == "primary/model"
assert body["models"] == ["primary/model", "backup/one", "backup/two"]
assert body["route"] == "fallback"
def test_no_fallbacks_means_no_routing_fields():
captured: dict = {}
complete(client_returning(completion_body(), captured=captured), "why", "solo/model")
body = captured["body"]
assert "models" not in body
assert "route" not in body
def test_served_model_comes_from_the_response_not_the_request():
body = completion_body(model="backup/one")
result = complete(client_returning(body), "why", "primary/model")
assert result.model == "backup/one"
def test_missing_cost_is_reported_as_unknown_not_zero():
body = completion_body()
del body["usage"]["cost"]
result = complete(client_returning(body), "why", SETTINGS.model)
assert result.cost_usd is None
assert result.cost_note == "cost: not reported"
def test_read_cost_handles_absent_usage():
assert read_cost(None) is None
def test_reasoning_tokens_are_reported_when_the_model_thinks():
body = completion_body(reasoning_tokens=815)
result = complete(client_returning(body), "why", SETTINGS.model)
assert result.reasoning_tokens == 815
def test_reasoning_tokens_default_to_zero_without_details():
body = completion_body()
del body["usage"]["completion_tokens_details"]
result = complete(client_returning(body), "why", SETTINGS.model)
assert result.reasoning_tokens == 0
assert read_reasoning_tokens(None) == 0
def test_a_length_finish_reason_is_surfaced_as_truncation():
"""A reasoning model can burn the whole budget before writing an answer;
`length` is the only signal that the reply is cut off."""
body = completion_body(finish_reason="length")
result = complete(client_returning(body), "why", SETTINGS.model)
assert result.finish_reason == "length"
assert result.was_truncated is True
def test_a_normal_stop_is_not_truncation():
result = complete(client_returning(completion_body()), "why", SETTINGS.model)
assert result.was_truncated is False
def test_status_errors_still_propagate():
body = {"error": {"code": 401, "message": "No auth credentials found"}}
with pytest.raises(AuthenticationError):
complete(client_returning(body, status=401), "why", SETTINGS.model)
Detailed breakdown
client_returningis the whole harness: build a transport that records the outgoing request and replies with a canned body, then hand it tobuild_client. Every test is then three lines.- The header test is the reason the harness captures requests. Attribution headers are easy to configure and easy to get silently wrong, since a misspelled header name produces no error, only a missing entry on a rankings page you may never check.
- Header lookup uses lowercase keys because
httpx2normalizes them. test_fallbacks_become_a_models_array_and_fallback_routepins the wire format. It asserts the primary model appears both asmodeland first inmodels, which is the contractcompletepromises.test_no_fallbacks_means_no_routing_fieldsis the other half. Without it, a refactor that always sentroute: "fallback"would pass every other test.test_served_model_comes_from_the_response_not_the_requestis the one that fails if someone “simplifies”Completion.modelto echo the requested id. With a fallback chain that difference is the entire point.test_missing_cost_is_reported_as_unknown_not_zeroguards the distinction between a free call and an unreported one. Defaulting to0.0would make a billing dashboard read as free.
Add the code: tests/test_errors.py
"""The failure ladder: every status OpenRouter documents maps to a message
that tells the reader what to do next."""
import httpx2
import openai
import pytest
from errors import describe_failure
def status_error(status: int, message: str) -> openai.APIStatusError:
"""Build the exception the SDK would raise for this status."""
request = httpx2.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
body = {"error": {"code": status, "message": message}}
response = httpx2.Response(status, json=body, request=request)
return openai.APIStatusError(message, response=response, body=body["error"])
@pytest.mark.parametrize(
("status", "expected"),
[
(401, "Key rejected (401)"),
(402, "Out of credits (402)"),
(429, "Rate limited (429)"),
(502, "Upstream model failed (502)"),
(503, "No provider matched your routing requirements (503)"),
],
)
def test_documented_statuses_get_actionable_messages(status: int, expected: str):
described = describe_failure(status_error(status, "upstream said no"))
assert described.startswith(expected)
assert "Server said: upstream said no" in described
def test_unmapped_status_still_reports_the_code():
assert "418" in describe_failure(status_error(418, "teapot"))
def stream_status_error(status: int, message: str) -> httpx2.HTTPStatusError:
"""Build the exception the raw streaming path raises.
`stream.py` calls `raise_for_status()` on an `httpx2` response, so the body
still carries the `{"error": {...}}` envelope the SDK would have unwrapped.
"""
request = httpx2.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
body = {"error": {"code": status, "message": message}}
response = httpx2.Response(status, json=body, request=request)
return httpx2.HTTPStatusError(f"Client error '{status}'", request=request, response=response)
def test_streaming_failures_run_through_the_same_ladder():
"""A 4xx while streaming is an httpx2 error, not an SDK one; without this
the reader gets a raw HTTPStatusError repr instead of the 401 message."""
described = describe_failure(stream_status_error(401, "User not found."))
assert described.startswith("Key rejected (401)")
assert "Server said: User not found." in described
def test_a_dropped_connection_while_streaming_is_a_network_problem():
request = httpx2.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
exc = httpx2.ConnectError("connection refused", request=request)
assert "Could not reach openrouter.ai" in describe_failure(exc)
def test_connection_error_is_named_as_a_network_problem():
request = httpx2.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
exc = openai.APIConnectionError(request=request)
assert "Could not reach openrouter.ai" in describe_failure(exc)
def test_unknown_exception_is_not_swallowed():
assert "Unexpected failure" in describe_failure(ValueError("something else"))
Detailed breakdown
status_errorconstructs the exception the way the SDK does, withbodyset to the inner error object. That mirrors the unwrapping described in Step 6, so the test would catch a_detailthat reached one level too deep.- The parametrized list is the documented status set. Adding a status to
errors.pywithout adding it here leaves it untested; adding it here first turns the table into the specification. - Each case asserts both halves of the message: the actionable prefix and the passed-through server text. Dropping the server’s own message is a common regression when someone tidies up the formatting.
APIConnectionErroris constructed from a request, since there is no response. This is the path exercised when your Wi-Fi drops mid-call.- The two streaming cases pin the second exception shape.
stream.pyposts withhttpx2, so its failures never reach anopenaiexception check. Before those tests existed,--streamwith a bad key printed anHTTPStatusErrorreprwhilemake askprinted “Key rejected (401)” for the same key.
Add the code: tests/test_stream.py
"""Streaming tests: reasoning deltas, and the two failures that arrive as
HTTP 200."""
import httpx2
import pytest
from config import Settings
from stream import (
CONTENT,
REASONING,
Delta,
StreamInterrupted,
StreamTruncated,
stream_completion,
)
SETTINGS = Settings(
api_key="test-key",
model="nvidia/nemotron-3-super-120b-a12b:free",
fallbacks=(),
referer="https://example.com",
title="orkit-tests",
timeout=5.0,
)
def event(delta: str = "", reasoning: str = "", finish=None) -> str:
parts = []
if delta:
parts.append(f'"content":"{delta}"')
if reasoning:
parts.append(f'"reasoning":"{reasoning}"')
finish_json = f'"{finish}"' if finish else "null"
return (
'{"id":"g","choices":[{"index":0,"delta":{'
+ ",".join(parts)
+ f'}},"finish_reason":{finish_json}}}]}}'
)
ERROR_EVENT = (
'{"id":"g","model":"m","provider":"P",'
'"error":{"code":502,"message":"upstream died"},'
'"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}'
)
def sse_client(events: list[str], status: int = 200) -> httpx2.Client:
payload = "".join(f"data: {e}\n\n" for e in events) + "data: [DONE]\n\n"
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(
status,
content=payload.encode(),
headers={"content-type": "text/event-stream"},
)
return httpx2.Client(transport=httpx2.MockTransport(handler))
def test_content_deltas_are_yielded_in_order():
deltas = list(
stream_completion(
SETTINGS,
"hi",
"m",
http_client=sse_client(
[event(delta="Hel"), event(delta="lo"), event(finish="stop")]
),
)
)
assert deltas == [Delta(CONTENT, "Hel"), Delta(CONTENT, "lo")]
def test_reasoning_is_a_separate_channel_from_the_answer():
"""Most free models are reasoning models: the answer arrives after the
thinking, on a different key."""
deltas = list(
stream_completion(
SETTINGS,
"hi",
"m",
http_client=sse_client(
[
event(reasoning="the user wants"),
event(reasoning=" a greeting"),
event(delta="Hello"),
event(finish="stop"),
]
),
)
)
assert [d.kind for d in deltas] == [REASONING, REASONING, CONTENT]
assert "".join(d.text for d in deltas if d.kind == CONTENT) == "Hello"
def test_one_event_carrying_both_yields_reasoning_first():
deltas = list(
stream_completion(
SETTINGS,
"hi",
"m",
http_client=sse_client([event(delta="A", reasoning="think"), event(finish="stop")]),
)
)
assert deltas == [Delta(REASONING, "think"), Delta(CONTENT, "A")]
def test_mid_stream_error_raises_instead_of_truncating_silently():
stream = stream_completion(
SETTINGS, "hi", "m", http_client=sse_client([event(delta="Hel"), ERROR_EVENT])
)
collected = []
with pytest.raises(StreamInterrupted, match=r"\[502\] upstream died"):
for delta in stream:
collected.append(delta)
assert collected == [Delta(CONTENT, "Hel")] # partial answer before the failure
def test_hitting_the_token_ceiling_raises_after_the_deltas():
"""finish_reason "length" means the answer is cut off, not finished. On a
reasoning model the budget can be gone before any content is emitted."""
stream = stream_completion(
SETTINGS,
"hi",
"m",
max_tokens=64,
http_client=sse_client([event(reasoning="thinking hard"), event(finish="length")]),
)
collected = []
with pytest.raises(StreamTruncated, match="64-token ceiling"):
for delta in stream:
collected.append(delta)
assert collected == [Delta(REASONING, "thinking hard")]
def test_a_normal_stop_does_not_raise():
list(
stream_completion(
SETTINGS, "hi", "m", http_client=sse_client([event(delta="ok", finish="stop")])
)
)
def test_processing_comments_are_ignored():
payload = (
": OPENROUTER PROCESSING\n\n"
+ f"data: {event(delta='Hel', finish='stop')}\n\n"
+ "data: [DONE]\n\n"
)
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(
200,
content=payload.encode(),
headers={"content-type": "text/event-stream"},
)
client = httpx2.Client(transport=httpx2.MockTransport(handler))
assert list(stream_completion(SETTINGS, "hi", "m", http_client=client)) == [
Delta(CONTENT, "Hel")
]
Detailed breakdown
event()builds SSE payloads from parts, so a test names only the channel it cares about. The helper writes thedata:prefix and the blank line between events, because testing against a shape the server never sends proves nothing about a parser whose whole job is the real one.test_reasoning_is_a_separate_channel_from_the_answeris the regression test for the bug this article shipped in draft. An earlier version yielded onlydelta.content, so a reasoning model streamed nothing at all and the CLI exited successfully with empty output.test_hitting_the_token_ceiling_raises_after_the_deltaspins the other half: reasoning arrived, no content did, andfinish_reason: "length"is the only signal that the answer was cut off rather than finished.- The mid-stream test asserts both halves. The partial answer arrived
(
Delta(CONTENT, "Hel")) and iteration then raised. A test that only checked the raise would pass an implementation that discarded everything the model had already produced. test_a_normal_stop_does_not_raiseguards the happy path, so a truncation check that fires too eagerly fails a test instead of breaking every call.
Run the suite
make test
Abridged below; a real run also prints the interpreter path, cachedir, and
rootdir, plus one line per test.
============================= test session starts ==============================
platform darwin -- Python 3.12.9, pytest-9.1.1, pluggy-1.6.0
configfile: pyproject.toml
testpaths: tests
collected 40 items
tests/test_catalog.py::test_prices_convert_from_per_token_to_per_million PASSED [ 2%]
tests/test_catalog.py::test_free_and_tool_support_are_derived_from_the_payload PASSED [ 5%]
...
tests/test_stream.py::test_reasoning_is_a_separate_channel_from_the_answer PASSED [ 87%]
tests/test_stream.py::test_hitting_the_token_ceiling_raises_after_the_deltas PASSED [ 95%]
tests/test_stream.py::test_processing_comments_are_ignored PASSED [100%]
============================== 40 passed in 0.32s ==============================
Forty tests, no key, no network, no spend.
Step 12: Run it against the real API
Everything so far has been verified without touching OpenRouter’s inference endpoints. Now add a key and make real calls. If you have not created one yet, it is at openrouter.ai/keys: sign in, click Create Key, give it a name, and optionally set a credit limit. The key is shown once, so copy it before closing the dialog. No payment method is needed for the free tier.
Set up the key
Ask before you have a key, because the failure mode is worth seeing once and this is the last moment you can see it:
uv run python ask.py "hello"
OPENROUTER_API_KEY is not set. Copy .env.example to .env and put your key there (get one at https://openrouter.ai/keys).
That is the intended behaviour, not a bug: one sentence naming the variable and the fix, and exit code 2. Now create the file and paste the key into it:
make setup # copies .env.example to .env if it is missing
$EDITOR .env # paste your key into OPENROUTER_API_KEY
Ask a question
make ask Q='Reply with exactly: ok'
The answer prints to stdout, and stderr carries the accounting line:
-- served by nvidia/nemotron-3-super-120b-a12b:free | 21 in / 37 out (36 reasoning) | cost: $0.000000
Read that line closely, because it contains the article’s most useful lesson. Nearly every output token went on reasoning, spent before the model wrote the two characters you asked for. Expect your own numbers to differ, and by a lot: six runs of this exact prompt against this exact model reported 32, 37, 47, 54, 146, and 158 output tokens. OpenRouter counts reasoning tokens as output tokens, but the two figures routinely fail to reconcile, and not in a fixed direction: three of those six runs reported more reasoning tokens than output tokens (55 against 54, 169 against 146, 160 against 158). Read them as a scale, not a subtraction. On a free model the cost is zero either way. On a paid reasoning model that ratio is the difference between the bill you expected and the bill you get.
Watch the fallback chain absorb a rate limit
Free capacity is shared, so a :free model returns 429 from its upstream
provider fairly often even when your own quota is untouched. That is what the
chain is for.
Seeing it work needs one piece of care: .env already set
OPENROUTER_FALLBACKS, so an ordinary make ask is already protected by a
chain, and a run that succeeds proves nothing on its own. To see the unprotected
behaviour, override that variable to empty for a single command. An environment
variable set on the command line beats the .env file, which is what makes this
a one-liner rather than an edit:
for i in 1 2 3; do
OPENROUTER_FALLBACKS= uv run python ask.py \
--model "google/gemma-4-31b-it:free" "Reply with exactly: ok" 2>&1 >/dev/null \
| grep -o "served by [^ ]*\|Rate limited (429)"
done
Rate limited (429)
Rate limited (429)
Rate limited (429)
Three for three, on a model that exists and is listed as free. Now let the chain back in and send the same model as the head of it, eight times:
for i in $(seq 1 8); do
uv run python ask.py --model "google/gemma-4-31b-it:free" \
--fallback "liquid/lfm-2.5-2.6b:free" "Reply with exactly: ok" 2>&1 >/dev/null \
| grep -o "served by [^ ]*"
done | sort | uniq -c
8 served by liquid/lfm-2.5-2.6b:free
Eight requests, zero failures, and not one of them answered by the model that
was asked for. How the eight split between primary and backup tracks how loaded
the primary happens to be that minute, so expect a different split. What did not
change between runs of this loop is that no request failed. That is models plus
route: "fallback" doing its work inside a single request, with no retry loop
in your code, and it is why response.model has to be read back rather than
assumed.
One limit worth knowing: an invalid model id is rejected, not fallen over.
uv run python ask.py --model "nonexistent/model" --fallback "liquid/lfm-2.5-2.6b:free" "hi"
OpenRouter returned 400. Server said: nonexistent/model is not a valid model ID
Fallback routing covers a model that fails, not a model that does not exist. Ids
are validated up front, so a typo in .env fails the whole request no matter how
long the chain behind it is.
Stream, with and without the thinking
make stream Q='Write a haiku about port 8080.'
Web traffic hums on
Eight zero eight zero, silent still
Dreams of code reply
Add --show-reasoning to watch the other channel, which goes to stderr:
uv run python ask.py --stream --show-reasoning "Count to three."
Stdout gets the answer. Stderr gets the thinking behind it, which in one run was a single line:
We need to count to three: "1, 2, 3". Probably just output numbers. Should be simple.
Both channels are the model’s own text, so both differ every run, and the reasoning channel varies more than you would expect for a fixed question. Three observed runs of this same three-word prompt produced 87 bytes of reasoning, 988 bytes, and a stretch in which the model talked itself through who might be asking and why. Size the terminal, or the log, for the long case.
If you point --stream at a small reasoning model with a prompt it overthinks,
you will meet the truncation guard instead:
answer incomplete: hit the 2048-token ceiling before finishing. On a reasoning model the budget covers reasoning tokens too, so raise max_tokens.
That message exists because the first draft of this code printed nothing at all in that situation and exited zero.
Check your rate-limit headroom
make key
Abridged below to the fields worth reading. The real response carries a dozen
more, including per-week and per-month usage and a rate_limit block that
OpenRouter marks deprecated.
{
"data": {
"label": "sk-or-v1-...",
"limit": null,
"limit_remaining": null,
"usage": 0,
"usage_daily": 0,
"is_free_tier": true,
"expires_at": null
}
}
is_free_tier says whether the account has ever bought credits; the higher
1,000-a-day cap needs ten of them. The usage figures are in
dollars, so they stay at zero while you are on :free models, which means they
cannot tell you how many of the day’s 50 requests you have spent. That makes
make key the wrong instrument for diagnosing a 429 on a free model. The text
in the error names the limit, and the next section reads it.
Troubleshooting
ModuleNotFoundError: No module named 'httpx'. You copied an http_client
injection recipe from a v1-era tutorial. openai 3.x installs httpx2 instead,
so the import fails before the type ever comes up. Run
uv pip list | grep -i httpx to confirm, then change the import. The parameter
is typed httpx2.Client | None as well, so a type checker flags the old form
too, though the SDK does not enforce it at runtime.
A streamed answer that is completely empty, exit code 0. The model is a
reasoning model and every token went to delta.reasoning. This is the default
experience on free models, not an edge case. stream.py yields both channels and
raises StreamTruncated when the ceiling is hit; if you wrote your own reader,
that is the bug.
An answer that stops mid-sentence. Check finish_reason. "length" means
the ceiling cut it off and the fix is a larger max_tokens, remembering that
reasoning tokens come out of the same budget. "error" means the provider died
mid-stream.
429 on the first call of the day. Free models are shared, so the 429 often
comes from the upstream provider rather than your account, and the server text
says which. Server said: Provider returned error is the provider, and
OPENROUTER_FALLBACKS absorbs it. Rate limit exceeded: free-models-per-day is
your own cap, and no chain gets around that one. Do not reach for usage_daily
here: it counts dollars, so it reads 0 on :free models however many requests
you have spent.
400 “is not a valid model ID” despite a fallback chain. Ids are validated
before routing, so a typo fails the request outright. Fallbacks cover a model
that fails, not one that does not exist. Run make models and copy the id.
401 with a key you just created. OpenRouter keys start sk-or-v1-. A key
copied from a different provider’s dashboard produces exactly this error, as
does a truncated paste. Quoting the value is safe: python-dotenv strips
matching quotes. An unmatched quote fails differently, dropping the line
entirely, so you get the missing-key message rather than a 401.
402 on a model you thought was free. Check the id ends in :free. The same
model often exists in both paid and free variants, and the paid one is the
default in most documentation.
503 rather than 502. No provider met your routing requirements. Usually that
is a provider block that is too strict, such as an only list whose providers
do not serve the model you asked for, and relaxing the constraints or leaving
allow_fallbacks at its default of true resolves it. The same status also
covers every eligible provider being overloaded, which OpenRouter answers with a
Retry-After header, so read the server text before editing constraints.
json.JSONDecodeError while streaming. Something tried to parse [DONE] or
an SSE comment line as JSON. Both are filtered in stream_completion.
Recap
A working OpenRouter setup on macOS, built out of five small modules:
- One base URL and one key turn the
openaipackage into a client for hundreds of models, with the model id as the only thing that changes. - The catalogue is public and free to query, so “which model, at what price” is a command rather than a guess against a documentation page.
- Every response carries its cost, and reading it takes one deliberate line because the field lives outside the OpenAI schema.
- A fallback chain is the default path for a plain call, not a refinement.
Free models 429 often enough that the chain is what makes the first call of
the day work, and
response.modelis how you learn who actually answered. - Reasoning is a separate channel that consumes the token budget and the bill without appearing in the answer.
- Failures are specific, including a 402 the SDK has no class for, a provider death that arrives with HTTP 200, and a truncation that looks exactly like a short reply.
- 40 tests run with no key and no network, with the ones that need a
request driven through
httpx2.MockTransport.
Next improvements
- Add tool calling, filtering the catalogue with
make models-toolsto pick a model that supports it, and compare how different vendors’ models behave on the same tool definitions. - Add a
providerblock to pin or exclude specific upstream providers, for example requiringdata_collection: "deny"for prompts you cannot let a provider retain. - Track spend over time by logging
cost_usdper call to SQLite, turning the per-request number into a per-feature budget. - Wrap the client as an MCP server so an agent can reach any OpenRouter model as a tool, in the shape used by Build an MCP Server with FastMCP and Python.