Using the OpenRouter API with Node.js

8 min read
On this page

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 openai npm 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 openai package. 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

  1. Sign up at openrouter.ai.
  2. Go to Keys and click Create Key.
  3. 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:

ModelGood forCost per 1M tokens (in / out)
openai/gpt-4o-miniCheap and fast. Fine for learning.$0.15 / $0.60
google/gemini-2.5-flashFast, huge context window$0.30 / $2.50
anthropic/claude-haiku-4.5Follows instructions well$1.00 / $5.00
openrouter/freePicks 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 MODEL for 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.


Share: