This OpenRouter Python tutorial takes you from a first HTTP request to a client with timeouts, bounded retries, and useful usage logs. Keep credentials server-side and validate results before using them.

Build a Python client that can survive production

OpenRouter accepts ordinary HTTP requests: send a Bearer token to /api/v1/chat/completions, name a model, and pass a messages array. This tutorial starts with raw HTTP so timeouts, retries, errors, and usage stay visible.

Checked October 15, 2026: model names, prices, and routing change. Confirm your model in the live catalog before deployment.

1. Create a clean project

mkdir openrouter-demo && cd openrouter-demo
python -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip requests
export OPENROUTER_API_KEY='your-key'
export OPENROUTER_MODEL='openai/gpt-5.2'

On Windows PowerShell activate with .\.venv\Scripts\Activate.ps1. Store the key in a secret manager for real deployments.

2. Run a boring smoke test

import os, requests
r = requests.post(
 "https://openrouter.ai/api/v1/chat/completions",
 headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}",
          "Content-Type": "application/json"},
 json={"model": os.environ["OPENROUTER_MODEL"],
       "messages":[{"role":"user","content":"Reply: smoke test passed"}],
       "max_tokens":32},
 timeout=(5,30),
)
print(r.status_code, r.text)
r.raise_for_status()

Test response shape and finish status rather than exact wording. Model output is not deterministic.

3. Add bounded retries

import os, random, time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests

RETRYABLE = {408, 429, 500, 502, 503, 504}

def retry_delay(response, attempt):
    value = response.headers.get("Retry-After") if response is not None else None
    if value:
        try:
            delay = float(value)
        except ValueError:
            try:
                delay = (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds()
            except (TypeError, ValueError, OverflowError):
                delay = None
        if delay is not None:
            return max(0.0, delay)
    return min(2 ** attempt, 30) + random.uniform(0, 0.25)

def complete(payload, max_attempts=4, deadline_seconds=120):
    deadline = time.monotonic() + deadline_seconds
    for attempt in range(max_attempts):
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise TimeoutError("request deadline exhausted")
        response = None
        try:
            response = requests.post(
                "https://openrouter.ai/api/v1/chat/completions",
                headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"},
                json=payload,
                timeout=(min(5, remaining), min(30, remaining)),
            )
        except (requests.Timeout, requests.ConnectionError):
            if attempt == max_attempts - 1:
                raise
        else:
            if response.status_code not in RETRYABLE:
                response.raise_for_status()
                data = response.json()
                if data.get("error"):
                    raise RuntimeError("OpenRouter returned an error envelope")
                return data
            if attempt == max_attempts - 1:
                response.raise_for_status()
        delay = retry_delay(response, attempt)
        if delay >= deadline - time.monotonic():
            raise TimeoutError("Retry-After exceeds the retry budget; queue for later")
        time.sleep(delay)
    raise RuntimeError("retry budget exhausted")

Do not retry 400 validation errors, 401 credentials, 402 credit limits, 403 policies, 404 model errors, or 413 payloads without changing the request. Let one layer own retries; stacked SDK and application retries multiply cost and latency.

4. Log useful facts, not prompts

data=complete({
 "model":os.environ["OPENROUTER_MODEL"],
 "messages":[{"role":"user","content":"Give one focus tip."}],
 "max_tokens":128,
})
usage=data.get("usage",{})
print({"id":data.get("id"),"model":data.get("model"),
       "tokens":usage.get("total_tokens"),"cost":usage.get("cost")})
print(data["choices"][0]["message"]["content"])

Keep request ID, returned model, latency, token counts, cost, and final outcome. Avoid logging API keys, full prompts, or sensitive output.

Platform versus provider failures

A 429 may come from an OpenRouter limit or an upstream provider. Record safe error metadata and rate-limit headers. With streaming, a failure may arrive inside an SSE event after HTTP 200; mark partial output as incomplete.

Production checklist

  • Bound connection and read timeouts.
  • Cap retries, output tokens, concurrency, and total job duration. The example checks a retry budget between attempts; a requests read timeout measures inactivity, so enforce a strict wall-clock deadline in your worker if required.
  • Validate the response before triggering external actions.
  • Use separate keys and budgets per environment.
  • Monitor the returned model and usage.
  • Test one reviewed fallback model.

Troubleshooting

  • 401: test GET /api/v1/key from the same process.
  • 402/403: inspect credits, key cap, workspace budget, and guardrails.
  • 404: check the live model slug.
  • 429: honor Retry-After, reduce concurrency, and review routing.
  • 200 but no answer: inspect error, finish reason, and token budget.

OpenRouter Python FAQ

Should I stream immediately?

No. Validate a non-streaming request first, then add SSE parsing and a clear partial-failure state when time-to-first-token matters.

Why does the returned model differ?

Aliases and fallback routing may resolve differently. Log the response model and pin a reviewed slug when reproducibility matters.

What next?

See the OpenAI SDK guide, 429 guide, and pillar guide.

Official references

Documentation reviewed 15 October 2026. Examples illustrate the request pattern; confirm model availability and account settings before use. No uptime or performance benchmark is claimed.

Similar Posts