Your first OpenRouter model call

Outcome
You get a repeatable response from OpenRouter on your own machine, read its token-usage metadata, and know how to recover from access and rate-limit errors.
Prerequisites
Lesson 0 complete (tools installed, OpenRouter API key set), Lesson 1 concepts (token, inference, prompt)
Cost
PaidSmall but not free. Running the sample makes one real OpenRouter inference call (a couple hundred tokens), billed against your OpenRouter API credit. Check current per-token prices on the linked OpenRouter models page before running.

Outcome

By the end of this lesson you will send one prompt to a hosted model through OpenRouter and get its reply back on your own machine. OpenRouter is a hosted service that speaks one API and routes your request to any of many underlying models, so you never manage the model yourself — the local track’s stand-in for Amazon Bedrock. API stands for Application Programming Interface, the defined way one program talks to another. You will also read the call’s token-usage numbers and know how to fix the two errors beginners hit most: authentication failure and rate limiting.

The model this course uses is OpenAI’s gpt-oss-120b, an open-weight model available through OpenRouter under the id openai/gpt-oss-120b. It is inexpensive and good at the structured, tool-using work the capstone needs — the same model the AWS track calls through Bedrock, just through a different socket.

Mental model

This is the “call the connector once” moment. In a visual tool you pick a connector, provide credentials, send one request, and read the response. Here the connector is OpenRouter, the request is your prompt, and the response is the model’s reply plus some metadata.

Two ideas carry over directly:

  • OpenRouter speaks the OpenAI API — the same request and response shape as calling OpenAI directly, just with a different base_url and a catalog of models to choose from. You send a list of chat messages; you get a message back. It is the connector’s “send message” action.
  • The usage metadata returned with every reply (input tokens, output tokens, total) is your run history for cost. Every call reports exactly what it consumed, so spend is never a mystery.

Where the analogy stops: a visual connector usually hides credentials and endpoint inside the connection setup. Here you keep the model id and API key in a small configuration file you can read, so switching models is a one-line change and nothing sensitive is hard-coded.

Prerequisites and cost

  • Lesson 0 complete: tools installed and .env holds a real OPENROUTER_API_KEY.
  • Nothing to enable on OpenRouter’s side beyond having a key with credit: an API key is the only gate, and you already created one in Lesson 0.
  • The course code checked out, with the local-track extra installed. From the agent/ folder run uv sync --extra openrouter once — this installs the openai Python client that the OpenAI-compatible call in this lesson (and the Strands provider in later lessons) needs.

Cost: running the sample makes one real, billable inference call. It is tiny (a couple hundred tokens), but it is not free. OpenRouter meters usage per token, with input and output priced separately and price varying by model. Check the current numbers on the OpenRouter models page before you run it. This course does not print dollar figures, because prices change; the models page is the source of truth.

Steps

Run these from the agent/ folder of the course.

  1. Copy the environment file, if you have not already from Lesson 0. Run cp .env.example .env. This file holds your provider choice, model id, and API key. It is ignored by Git so it never gets committed. One mechanic to know: uv does not read .env on its own. Every run command in this course passes --env-file .env to uv run, which loads the file’s values into the environment where config.py reads them. If you forget the flag, the code silently falls back to its built-in defaults — which do not include your API key, so the call fails with the access error covered below.

  2. Confirm the values. Open .env and confirm PROVIDER is openrouter and OPENROUTER_API_KEY holds your real key. Leave MODEL_ID unset; the code defaults it to openai/gpt-oss-120b for this provider, per the research log.

  3. Install the local-track extra. Run uv sync --extra openrouter. This pulls in the openai client used by both this lesson’s direct call and later lessons’ Strands agent.

  4. Read the code below so you can explain every line before you run it.

  5. Run the call. From agent/, run:

    uv run --env-file .env python -m intake.openrouter_call

    This prints a cost note, sends one prompt, and prints the reply plus token usage.

Smallest code sample

Two files do the work. First, config.py reads the provider, model id, and API key from your environment, with the researched default model id as a fallback. Keeping these out of the code is why you can change providers or models by editing .env instead of editing source — the same file the AWS track’s Lesson 2 reads, since both tracks share one configuration seam.

"""Shared configuration, read from environment variables.

Standard library only. Keeping the provider, model id, and memory/session settings
out of source (AGENTS.md: "Keep model ID, region, memory ID ... configurable") means
a learner switches tracks and environments by changing `.env`, not by editing code.

The course has two tracks, chosen by the `PROVIDER` value:
  - "bedrock"    -> Amazon Bedrock (AWS track). Uses MODEL_ID + AWS_REGION, and
                    AgentCore Memory (MEMORY_ID) for cross-session memory.
  - "openrouter" -> OpenRouter (local track). Uses MODEL_ID + OPENROUTER_API_KEY,
                    and a local file session store (SESSIONS_DIR) for memory.
Same agent, same model family; only the transport and the memory backend differ.
"""

from __future__ import annotations

import os
from dataclasses import dataclass

# The two supported inference providers.
BEDROCK = "bedrock"
OPENROUTER = "openrouter"

_DEFAULT_PROVIDER = BEDROCK

# Per-provider default model id. Same model (OpenAI's gpt-oss-120b), two id formats:
# Bedrock uses its own foundation-model id; OpenRouter uses "<vendor>/<model>".
_DEFAULT_MODEL_ID = {
    BEDROCK: "openai.gpt-oss-120b-1:0",
    OPENROUTER: "openai/gpt-oss-120b",
}

# Bedrock-only. Ignored on the local track.
_DEFAULT_REGION = "us-east-2"

# Local track: where the file session store writes its per-user session data.
_DEFAULT_SESSIONS_DIR = "data/sessions"


class ConfigError(RuntimeError):
    """A required configuration value is missing or empty."""


@dataclass(frozen=True)
class Config:
    """Resolved configuration for one run.

    Fields used by both tracks: `provider`, `model_id`. The rest are track-specific
    and optional, so a value irrelevant to your track never has to be set:
      - `region`             -> Bedrock only.
      - `memory_id`          -> AWS-track memory (AgentCore Memory).
      - `openrouter_api_key` -> local-track inference (OpenRouter).
      - `sessions_dir`       -> local-track memory (file session store).
    """

    provider: str
    model_id: str
    region: str
    memory_id: str | None
    openrouter_api_key: str | None
    sessions_dir: str

    def require_memory_id(self) -> str:
        """Return the AgentCore Memory id or explain exactly what to set.

        AgentCore Memory has no local emulation, so this cannot be defaulted.
        """
        if not self.memory_id:
            raise ConfigError(
                "MEMORY_ID is not set. AgentCore Memory is a cloud resource with "
                "no local emulation. Create one in your AWS account and set "
                "MEMORY_ID in your environment (see .env.example)."
            )
        return self.memory_id

    def require_openrouter_api_key(self) -> str:
        """Return the OpenRouter API key or explain exactly what to set.

        Validated here (at model-build time), not in load_config, so an AWS-track
        learner who never uses OpenRouter is never forced to set it.
        """
        if not self.openrouter_api_key:
            raise ConfigError(
                "OPENROUTER_API_KEY is not set. The local track calls OpenRouter "
                "for inference. Create a key at https://openrouter.ai/keys and set "
                "OPENROUTER_API_KEY in your environment (see .env.example)."
            )
        return self.openrouter_api_key


def load_config() -> Config:
    """Build a Config from the current environment.

    Provider and model id fall back to the researched defaults; secrets and ids
    (memory id, API key) do not, because a wrong value would point at the wrong
    account's data or fail confusingly.
    """
    provider = (os.environ.get("PROVIDER") or "").strip().lower() or _DEFAULT_PROVIDER
    if provider not in (BEDROCK, OPENROUTER):
        raise ConfigError(
            f"PROVIDER must be '{BEDROCK}' or '{OPENROUTER}', got '{provider}'."
        )
    return Config(
        provider=provider,
        model_id=(os.environ.get("MODEL_ID") or "").strip()
        or _DEFAULT_MODEL_ID[provider],
        region=(os.environ.get("AWS_REGION") or "").strip() or _DEFAULT_REGION,
        memory_id=(os.environ.get("MEMORY_ID") or "").strip() or None,
        openrouter_api_key=(os.environ.get("OPENROUTER_API_KEY") or "").strip() or None,
        sessions_dir=(os.environ.get("SESSIONS_DIR") or "").strip()
        or _DEFAULT_SESSIONS_DIR,
    )

Second, openrouter_call.py makes the actual call. Read call_model: it creates an OpenAI client pointed at OpenRouter’s base URL with explicit timeouts and a small retry limit, sends one message through the chat completions API, and turns the two most common errors into plain-language guidance instead of a raw stack trace. _parse_reply pulls the answer text and the token counts out of the response — the same shape bedrock_call.py returns on the AWS track, so everything downstream of this lesson reads identically on either track.

"""Lesson 2 (local track): one OpenRouter chat call, no agent framework yet.

The local-track twin of `bedrock_call.py`. Same shape: send one message, read the
reply and the token-usage metadata, and turn the two errors a beginner hits most
(a bad/missing API key, and rate limiting) into plain-language remediation instead
of a stack trace. Only the transport differs — OpenRouter speaks the OpenAI API.

Running this DOES call OpenRouter and spends a small amount of API credit, so it is
guarded behind `if __name__ == "__main__"` with a printed cost note.

Needs the local-track extra: `uv sync --extra openrouter` (installs the `openai`
client). Set `PROVIDER=openrouter` and `OPENROUTER_API_KEY` in your environment.
"""

from __future__ import annotations

from dataclasses import dataclass

from intake.config import Config, load_config
from intake.model import OPENROUTER_BASE_URL

# Bound the network call. Without these a hung connection blocks forever and a
# transient error retries indefinitely. Small, explicit limits are the lesson.
_TIMEOUT_SECONDS = 30
_MAX_RETRIES = 2

# Keep the demo cheap and bounded: a short answer is all we need to see usage.
_MAX_OUTPUT_TOKENS = 200


@dataclass(frozen=True)
class ModelReply:
    """The parts of a chat-completion response the lesson inspects."""

    text: str
    input_tokens: int
    output_tokens: int
    total_tokens: int


class ModelAccessError(RuntimeError):
    """The API key is missing, wrong, or not authorized for this model."""


class ModelThrottledError(RuntimeError):
    """OpenRouter rate-limited the request; retry later or slow down."""


def call_model(prompt: str, config: Config | None = None) -> ModelReply:
    """Send one prompt to OpenRouter via the OpenAI chat API and return the reply.

    Raises ModelAccessError / ModelThrottledError with remediation text for the two
    most common beginner failures; other errors propagate unchanged so they are not
    silently swallowed. `openai` is imported here (not at module top) so this file
    stays importable without the `openrouter` extra installed.
    """
    cfg = config or load_config()
    import openai

    client = openai.OpenAI(
        api_key=cfg.require_openrouter_api_key(),
        base_url=OPENROUTER_BASE_URL,
        timeout=_TIMEOUT_SECONDS,
        max_retries=_MAX_RETRIES,
    )

    try:
        response = client.chat.completions.create(
            model=cfg.model_id,
            messages=[{"role": "user", "content": prompt}],
            max_tokens=_MAX_OUTPUT_TOKENS,
        )
    except openai.AuthenticationError as err:
        raise ModelAccessError(
            f"Authentication failed for OpenRouter model '{cfg.model_id}'. Check "
            "that OPENROUTER_API_KEY is set to a valid key (create one at "
            "https://openrouter.ai/keys) and that your account can use this model."
        ) from err
    except openai.RateLimitError as err:
        raise ModelThrottledError(
            "OpenRouter rate-limited this request. Wait a few seconds and retry, "
            "or check your account's rate limits and credit balance."
        ) from err

    return _parse_reply(response)


def _parse_reply(response) -> ModelReply:
    """Pull answer text and token usage out of a chat-completion response.

    Validate the shape at this boundary rather than trusting the SDK object deep in
    caller code.
    """
    text = response.choices[0].message.content or ""
    usage = response.usage
    return ModelReply(
        text=text,
        input_tokens=usage.prompt_tokens,
        output_tokens=usage.completion_tokens,
        total_tokens=usage.total_tokens,
    )


if __name__ == "__main__":
    # COST NOTE: this makes a real, billable OpenRouter inference call using your
    # API credit. It is tiny (a couple hundred tokens) but not free.
    print("COST NOTE: this calls OpenRouter and spends a small amount of API credit.")
    reply = call_model("In one sentence, what is an automation intake request?")
    print("\nReply:", reply.text)
    print(
        f"Tokens: input={reply.input_tokens} "
        f"output={reply.output_tokens} total={reply.total_tokens}"
    )

The base_url used here (OPENROUTER_BASE_URL, imported from model.py) is what makes this an OpenRouter call instead of a call to OpenAI directly: same API shape, different endpoint. The <MODEL_ID> comes from your .env file (see config.py) and is never hard-coded into the call.

Expected output

This step calls OpenRouter, which needs your API key and spends a small amount of credit, so it cannot be run for you here. The block below is the shape of the output, not a transcript. The reply wording is generated fresh each run, so your exact sentence and exact token counts will differ (output shape; exact text and numbers vary per run):

COST NOTE: this calls OpenRouter and spends a small amount of API credit.

Reply: <one sentence of model-generated text describing an automation intake request>
Tokens: input=<N> output=<N> total=<N>

The checkpoint is met when you see a non-empty Reply: line and a Tokens: line where total equals input + output. Run the command a second time: the sentence changes slightly but the shape stays the same. That variation is the probabilistic behavior from Lesson 1, seen firsthand.

One common failure

Symptom: the command ends with a message like “Authentication failed for OpenRouter model ‘…’. Check that OPENROUTER_API_KEY is set to a valid key…” instead of a reply.

Diagnosis: your request reached OpenRouter but was rejected — either OPENROUTER_API_KEY is missing, misspelled, still the commented-out sample value, or the key has been revoked. The code catches the OpenAI client’s AuthenticationError and rewrites it as that plain-language ModelAccessError message, so this is expected, recoverable behavior, not a crash.

Fix: open .env and confirm OPENROUTER_API_KEY is set to a real key copied from openrouter.ai/keys, with no extra quotes or spaces, then run the command again with --env-file .env (a missing flag silently drops the key and produces this same error).

A second common failure is a rate-limit message (“OpenRouter rate-limited this request. Wait a few seconds and retry…”). That means you sent requests faster than your account’s limit allows, or your account has run out of credit. Wait a few seconds and retry, or check your credit balance on OpenRouter if it keeps happening. The code catches the client’s RateLimitError and surfaces this guidance for you as a ModelThrottledError.

Why this works

Three deliberate choices make this first call reliable. The provider and model id live in configuration, so the same code runs against any model OpenRouter offers without edits and nothing sensitive is baked into source. The network call has explicit timeouts and a bounded retry count, so a hung connection fails in seconds instead of hanging forever, and a transient blip retries a couple of times instead of endlessly. And the two most common errors are caught and translated into actions you can take, so a beginner sees a named setting to check rather than a forty-line traceback. These are the same boundary habits the AWS track’s bedrock_call.py uses — the transport changed, the reliability habits did not.

Verify it yourself

  1. Run uv run --env-file .env python -m intake.openrouter_call from agent/ and confirm you get a Reply: line and a Tokens: line.
  2. Run it a second time and confirm the reply wording changes while the token line still adds up (input + output = total).
  3. Temporarily set OPENROUTER_API_KEY in .env to an obviously wrong value (for example sk-or-v1-wrong), run the command, and confirm you get a plain-language ModelAccessError rather than a crash. Then restore your real key.

Passing all three means you have a repeatable OpenRouter call and you have seen both the success path and the main failure path.

Cleanup

There is nothing to delete. This lesson creates no standing resource; each run is a single call that finishes immediately. To stop any further spend, simply do not run the command again. Keep your .env file for the next lesson; it is already ignored by Git, so it will not be committed.

Go deeper (1)