I wanted to try a few different AI models from a Node.js script without signing up for three different services. OpenRouter turned out to be the easiest way to do that. This post is what I wish I had read first: what OpenRouter is, the one package you need, and a small chat app that runs in the terminal.
What OpenRouter is
OpenRouter is a middleman. You send it a request, it passes that request on to OpenAI, Anthropic, Google or whoever hosts the model you asked for, and it hands the answer back. Every model looks the same from your side.
Why I like it:
- One account and one key. The bill and the usage log are in one place.
- Changing models means changing one string. Same code for GPT, Claude and Gemini.
- It uses the same request format as OpenAI, so the normal
openainpm package works with it. - Some models are free, which is nice while you’re learning.
What you need
Not much.
- Node.js 22 or newer. Check with
node --version. Installers are at nodejs.org if you need to update. - The
openaipackage. It’s OpenAI’s official client library, but it works with anything that speaks the OpenAI format, and OpenRouter does. It does the HTTP requests, retries and streaming so you don’t have to. - An OpenRouter API key. Free to create. More on that in a second.
That’s the whole list. npm comes with Node, so you already have it.
You might have seen other tutorials add dotenv to load the key from a
.env file. For a single throwaway script I skip that and put the key
straight in the file. I’ll come back to when that’s a bad idea.
Step 1: Get a key
- Sign up at openrouter.ai.
- Go to Keys and click Create Key.
- Copy it somewhere safe. It starts with
sk-or-v1-and you only see it once.
You can add a few dollars under Credits, or stick to the free models for now.
Step 2: Set up the project
mkdir openrouter-chat && cd openrouter-chat
npm init -y
npm pkg set type=module
npm install openai
The type=module line lets you write import instead of require. The
rest is standard.
The pieces
Before the full app, here are the things it’s made of. Each one is a few lines.
Make a client
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: "sk-or-v1-your-key-here",
});
The baseURL is the only difference from talking to OpenAI directly. Make
one client and reuse it.
Send a message
const completion = await client.chat.completions.create({
model: "openai/gpt-4o-mini",
messages: [{ role: "user", content: "What is an API? One sentence." }],
});
console.log(completion.choices[0].message.content);
messages is the conversation. The reply comes back in choices[0]. Put
this under the client code, save it as hello.js, run node hello.js, and
you’ll know your key works.
If you’re curious what it cost, completion.usage has the token counts.
Give it memory
The model forgets everything between requests. To have a conversation you send the whole history each time. There are three roles:
system: how the assistant should behave. Goes first.user: what the person typed.assistant: what the model said before.
const messages = [
{ role: "system", content: "You are a patient tutor. Use plain English." },
{ role: "user", content: "What is Node.js?" },
{ role: "assistant", content: "Node.js runs JavaScript outside the browser." },
{ role: "user", content: "What do people build with it?" },
];
Send that and the model answers the last question knowing what came before. Your job is just to keep pushing to the array.
Pick a model
Model IDs look like vendor/model-name. Some I’ve used:
| Model | Good for | Cost per 1M tokens (in / out) |
|---|---|---|
openai/gpt-4o-mini | Cheap and fast. Fine for learning. | $0.15 / $0.60 |
google/gemini-2.5-flash | Fast, huge context window | $0.30 / $2.50 |
anthropic/claude-haiku-4.5 | Follows instructions well | $1.00 / $5.00 |
openrouter/free | Picks a free model for you | $0 |
Prices are from when I wrote this. The live list is at
openrouter.ai/models. Two more things worth
knowing: some IDs have a :free version, like google/gemma-4-31b-it:free,
with tighter rate limits. And IDs like ~anthropic/claude-haiku-latest
always point at the newest model in that family.
Stream the reply
Waiting for a whole answer feels slow. Streaming prints it word by word as it arrives, like ChatGPT does.
const stream = await client.chat.completions.create({
model: "openai/gpt-4o-mini",
messages: [{ role: "user", content: "Tell me a short story about a robot." }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
With stream: true you get something you can loop over. Each chunk has a
bit of text in delta.content. Some chunks are empty, hence the ?? "".
Handle errors
Keys get typed wrong, credits run out. When the server says no, the library throws an error whose message starts with the HTTP status code.
try {
const completion = await client.chat.completions.create({ model, messages });
console.log(completion.choices[0].message.content);
} catch (error) {
console.error(`Request failed: ${error.message}`);
}
The ones you’ll see most: 401 means the key is wrong, 402 means you’re out of credits, 429 means slow down, and 400 usually means a typo in the model ID.
The whole thing: a terminal chat app
Here’s all of the above in one file. Save it as chat.js, paste your key in
at the top, and run it with node chat.js.
// chat.js
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import OpenAI from "openai";
// Paste your key here. Fine for a local script you keep to yourself.
const API_KEY = "sk-or-v1-paste-your-key-here";
const MODEL = "openai/gpt-4o-mini";
if (API_KEY === "sk-or-v1-paste-your-key-here") {
console.error("Open chat.js and paste your OpenRouter key into API_KEY.");
process.exit(1);
}
const client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: API_KEY,
});
// The conversation so far. The system message sets the tone.
const messages = [
{ role: "system", content: "You are a friendly assistant. Keep answers short." },
];
// Send the whole conversation and print the reply as it streams in.
async function ask(question) {
messages.push({ role: "user", content: question });
const stream = await client.chat.completions.create({
model: MODEL,
messages,
stream: true,
});
let reply = "";
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? "";
output.write(text);
reply += text;
}
output.write("\n\n");
messages.push({ role: "assistant", content: reply });
}
async function main() {
const readlineInterface = readline.createInterface({ input, output });
// Ctrl+C or Ctrl+D closes readline. Exit cleanly instead of crashing.
readlineInterface.on("close", () => {
console.log("\nBye!");
process.exit(0);
});
console.log(`Chatting with ${MODEL}. Type "exit" to quit.\n`);
while (true) {
const question = (await readlineInterface.question("You: ")).trim();
if (question === "") continue;
if (question.toLowerCase() === "exit") break;
output.write("AI: ");
try {
await ask(question);
} catch (error) {
messages.pop(); // drop the question that failed
console.error(`\nRequest failed: ${error.message}\n`);
}
}
readlineInterface.close();
}
main();
Running it looks like this:
Chatting with openai/gpt-4o-mini. Type "exit" to quit.
You: What is the capital of Australia?
AI: The capital of Australia is Canberra.
You: How far is that from Sydney?
AI: About 250 km south-west of Sydney.
You: exit
Bye!
The second answer knew what “that” meant because the whole conversation went along with the question.
What’s going on
The top of the file is the config: your key and the model. The if right
after it catches the case where you forgot to paste the key, so you get a
plain sentence instead of a confusing 401 later.
messages is the memory. It starts with the system message and grows as you
talk.
ask pushes your question, sends the whole array with stream: true, prints
each bit of text as it arrives, then stores the finished reply as an
assistant message so the next question has context.
main is the loop. readline/promises is built into Node and lets you
await a line of input. Empty lines are skipped, exit ends the loop, and
everything else goes to ask. If a request fails, the error is printed and
the loop keeps going. The close handler is there because pressing
Ctrl+C closes readline, and without it the next
readlineInterface.question call throws a stack trace at you.
About that hardcoded key
Putting the key in the file is fine for a script on your own machine that nobody else sees. It stops being fine the moment the file goes anywhere: a git repo, a gist, a screenshot. Bots scan public GitHub for keys like this constantly.
If you do want to share or commit the script, move the key out first. The least-effort way needs no extra package. Change the top line to:
const API_KEY = process.env.OPENROUTER_API_KEY;
Put the key in a file called .env:
OPENROUTER_API_KEY=sk-or-v1-your-key-here
Add .env to your .gitignore, then run the script with:
node --env-file=.env chat.js
Node has had --env-file since version 20.6, so there’s still nothing to
install.
Where to go from here
- Swap
MODELfor a Claude or Gemini ID and see how the answers change. - Ask for JSON back with
response_format: { type: "json_object" }. - Look into tool calling, where the model can call your own functions. Same request format as OpenAI.
- Trim old messages once a conversation gets long, or you’ll hit the model’s context limit and pay for tokens you don’t need.
- The docs at openrouter.ai/docs cover everything else.
That’s it. One package, one file, one key.