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 cost field 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 :free model 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 reasoning channel that never appears in the answer. Read only content and 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.py and a Makefile whose bare make prints a help screen.
  • 40 pytest tests that run with no API key and no network.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify with uv --version. This article was validated on uv 0.11.26.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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 :free models, 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

  • .env and *.env are ignored, .env.example is exempted. The ! line has to come after the patterns it overrides, because git applies the last matching rule. It is belt and braces here: *.env matches 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 because uv recreates it from uv.lock in 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

  • openai 3.x ships httpx2, not httpx. Check what landed:

    uv pip list | grep -i httpx
    

    You will see httpx2 2.12.0 and no httpx at all. This matters more than it looks. The injection recipe from every v1-era tutorial (http_client=httpx.Client(...)) now dies at the import, because httpx is not installed; the parameter is also typed httpx2.Client | None, so a type checker rejects the old form even where one is importable. Every module below imports httpx2, and the tests use httpx2.MockTransport. There is no separate HTTP dependency to add.

  • pythonpath = ["."] lets the tests import the flat modules (config, client) without a src/ layout or an editable install.

  • testpaths = ["tests"] keeps a bare uv run pytest from 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_FALLBACKS is 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_URL lives 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_settings takes an optional env dict. 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 exported OPENROUTER_API_KEY beats the file, which is what you want in CI.
  • A blank key is treated as a missing key. .strip() catches the common case of OPENROUTER_API_KEY= left in the file, or a trailing space pasted along with the token.
  • _split distinguishes unset from empty. An absent OPENROUTER_FALLBACKS means “use the defaults”; an explicitly empty one means “no fallbacks”. A plain env.get(..., default) would collapse those two into one and leave no way to turn fallbacks off.
  • Settings is frozen. Configuration read once at startup should not be mutable afterwards; a frozen dataclass makes an accidental write a TypeError.

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_models is split from fetch_models so 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 explicit null; the same pattern guards context_length and supported_parameters. Entries in a 400-model catalogue are not uniform, and one sparse record should not crash the listing.
  • is_free keys off the :free suffix, 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_tools reads supported_parameters. If you intend to do tool calling, filter on this rather than assuming: plenty of models in the catalogue accept temperature and nothing more.
  • fetch_models accepts an injected client and only closes what it created. Passing a client in is how the test drives it through MockTransport; 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_url is the whole integration. Everything else about the SDK stays as it is documented for OpenAI. client.chat.completions.create remains supported indefinitely in openai 3.x even though responses.create is now the SDK’s headline API, and chat completions is the surface OpenRouter documents, so that is what this uses.
  • max_retries=0 is 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-Referer and X-OpenRouter-Title put your app on OpenRouter’s public rankings; omit them and calls still work. X-Title is the older spelling and is still accepted, so existing code does not break.
  • read_cost looks in model_extra first, then falls back to getattr. Both routes work with the current SDK; checking model_extra first states the intent, which is that cost is an extension field rather than something the SDK promises. A None result is reported as unknown rather than as zero, because “free” and “not reported” are different facts.
  • fallbacks become models plus route: "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 through extra_body instead of a named SDK parameter; the SDK merges that dict into the JSON it posts. OpenRouter’s current documentation describes the models array on its own and no longer mentions route, which is still accepted; if you are trimming the body, that is the field to drop.
  • response.model is 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 the openrouter/auto router, which picks on your behalf.
  • extra_body or None avoids sending an empty object when there are no fallbacks, keeping the request body identical to a plain OpenAI call.
  • reasoning_tokens are billed, invisible, and count against max_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_reason is 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_truncated gives callers one obvious thing to check.
  • max_tokens defaults 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 one match statement as the single place the mapping lives.
  • APIConnectionError is checked first because it is not an APIStatusError. There is no response and no status code to read: the request never arrived. httpx2.TransportError is its raw-request twin, thrown when the streaming call in Step 7 cannot connect.
  • _as_status_error rewraps a streaming failure into the SDK’s shape. A 4xx from stream.py is an httpx2.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 _detail does not.
  • exc.body is already unwrapped. OpenRouter sends {"error": {"code": 402, "message": "..."}}, and the SDK hands you the inner object. Reaching for exc.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 return keeps unknown exceptions visible. Swallowing them into a friendly string would hide bugs in your own code, so anything unrecognized is reported with its repr.

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

  • Delta carries 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 lets ask.py print 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. StreamInterrupted says the provider broke; StreamTruncated says 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_tokens defaults 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 PROCESSING to 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 raises ResponseNotRead and the reader sees that instead of the 401 underneath it. Reading it on a 4xx first is what lets errors.py quote what OpenRouter said. A MockTransport holds 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 early return would skip it, which is a quiet way to reintroduce the empty-answer bug.
  • owns_client keeps 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: 0 success, 1 the call failed or the answer is incomplete, 2 the 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.
  • --fallback overrides OPENROUTER_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 first make ask of the day work.
  • The chain applies to make ask, not to make stream. stream_completion posts a plain body with one model in it, so --fallback and OPENROUTER_FALLBACKS are 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.
  • StreamInterrupted and StreamTruncated are 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 Exception is intentional and narrow in effect. Every exception goes to describe_failure, which reports unrecognized errors with their repr rather 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 := help makes a bare make print the help screen. Relying on help being the first target works too, but breaks the moment someone adds a target above it.
  • Q ?= ... gives the prompt a default that make ask Q='...' overrides. The ?= form means an exported Q in your shell also wins.
  • setup never clobbers an existing .env. The test -f guard is what stops a re-run from overwriting a working key with the template placeholder.
  • key sources .env in the recipe’s own shell. set -a exports what the file defines so curl can 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.
  • test needs 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; a ConfigError that 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 null fields. A fixture with tidy values would pass while the real catalogue crashed the parser.
  • pytest.approx is required, not stylistic. float("0.000000177") * 1_000_000 is 0.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_raise is the sparse-record guard. It is the test that would have caught a None * 1_000_000 crash before it reached a reader running make models on a day when some vendor shipped an incomplete entry.
  • The transport test asserts no Authorization header 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_returning is the whole harness: build a transport that records the outgoing request and replies with a canned body, then hand it to build_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 httpx2 normalizes them.
  • test_fallbacks_become_a_models_array_and_fallback_route pins the wire format. It asserts the primary model appears both as model and first in models, which is the contract complete promises.
  • test_no_fallbacks_means_no_routing_fields is the other half. Without it, a refactor that always sent route: "fallback" would pass every other test.
  • test_served_model_comes_from_the_response_not_the_request is the one that fails if someone “simplifies” Completion.model to echo the requested id. With a fallback chain that difference is the entire point.
  • test_missing_cost_is_reported_as_unknown_not_zero guards the distinction between a free call and an unreported one. Defaulting to 0.0 would 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_error constructs the exception the way the SDK does, with body set to the inner error object. That mirrors the unwrapping described in Step 6, so the test would catch a _detail that reached one level too deep.
  • The parametrized list is the documented status set. Adding a status to errors.py without 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.
  • APIConnectionError is 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.py posts with httpx2, so its failures never reach an openai exception check. Before those tests existed, --stream with a bad key printed an HTTPStatusError repr while make ask printed “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 the data: 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_answer is the regression test for the bug this article shipped in draft. An earlier version yielded only delta.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_deltas pins the other half: reasoning arrived, no content did, and finish_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_raise guards 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 openai package 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.model is 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-tools to pick a model that supports it, and compare how different vendors’ models behave on the same tool definitions.
  • Add a provider block to pin or exclude specific upstream providers, for example requiring data_collection: "deny" for prompts you cannot let a provider retain.
  • Track spend over time by logging cost_usd per 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.