Your first OpenRouter model call
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_urland 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
.envholds a realOPENROUTER_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 runuv sync --extra openrouteronce — this installs theopenaiPython 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.
-
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:uvdoes not read.envon its own. Every run command in this course passes--env-file .envtouv run, which loads the file’s values into the environment whereconfig.pyreads 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. -
Confirm the values. Open
.envand confirmPROVIDERisopenrouterandOPENROUTER_API_KEYholds your real key. LeaveMODEL_IDunset; the code defaults it toopenai/gpt-oss-120bfor this provider, per the research log. -
Install the local-track extra. Run
uv sync --extra openrouter. This pulls in theopenaiclient used by both this lesson’s direct call and later lessons’ Strands agent. -
Read the code below so you can explain every line before you run it.
-
Run the call. From
agent/, run:uv run --env-file .env python -m intake.openrouter_callThis 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
- Run
uv run --env-file .env python -m intake.openrouter_callfromagent/and confirm you get aReply:line and aTokens:line. - Run it a second time and confirm the reply wording changes while the token
line still adds up (
input + output = total). - Temporarily set
OPENROUTER_API_KEYin.envto an obviously wrong value (for examplesk-or-v1-wrong), run the command, and confirm you get a plain-languageModelAccessErrorrather 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.

