> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Use OpenRouter on Sevalla

> Call models from many providers through OpenRouter from a Sevalla application: fallbacks, provider routing, free models, streaming, cost per key, and OpenRouter in LiteLLM, Open WebUI and n8n.

OpenRouter is one OpenAI-compatible API, at `https://openrouter.ai/api/v1`, for models from Anthropic, OpenAI, Google and many other providers. A Sevalla application calls it over HTTPS with an API key in a runtime-only environment variable, so switching models or providers is a change to a model ID, not to your infrastructure. This guide deploys a small Express server that streams answers from OpenRouter with a fallback model and a data policy, logs the cost of every answer, and then uses OpenRouter as the model provider for LiteLLM, Open WebUI, n8n and the AI SDK.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Node.js 24, npm), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `node server.js`, from `scripts.start` in `package.json` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process |
| Services | None on Sevalla. The server calls OpenRouter over HTTPS |
| Disk | None |
| Health check | `GET /health` returns `200` with `ok` |
| Size | **H1** for the web process |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `OPENROUTER_API_KEY` | Yes | Runtime | Yes | An OpenRouter API key for this application, see [Create an OpenRouter API key](#create-an-openrouter-api-key) |
| `APP_TOKEN` | Yes | Runtime | Yes | Generate with `openssl rand -hex 32`. Clients send it as `Authorization: Bearer <token>` |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

## Before you begin

* A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See [Git providers](https://docs.sevalla.com/git/overview.md).
* Node.js 24 or later on your computer.
* An [OpenRouter](https://openrouter.ai) account. Paid models need credits on the account; [free models](#use-free-models) don't.

## Create an OpenRouter API key

Give every application its own OpenRouter key. You can then cap what each application spends, see its usage, and revoke one key without touching the others.

**1. Create the key**

Open [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) and click **Create API Key**. Name it after the application, for example `storefront-assistant`.

**2. Set a credit limit**

Under **Key limit**, enter a **Credit limit** in US dollars, for example `5`, and pick whether it resets daily, weekly or monthly or never. When the key has spent its limit, its requests fail until the limit resets or you raise it.

**3. Copy the key**

Copy the key, which starts with `sk-or-v1-`, into your password manager.

Check the key and its limit with OpenRouter's `GET /api/v1/key`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://openrouter.ai/api/v1/key -H "Authorization: Bearer $OPENROUTER_API_KEY" \
  | jq -c '.data | {limit, limit_remaining, limit_reset, usage}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"limit":100,"limit_remaining":99.9928592,"limit_reset":null,"usage":0.0071408}
```

`limit` is the key's credit limit and `usage` what it has spent, in US dollars. A key with no limit shows `null`.

## Create the project

Create a folder, set the package's module type, start script and Node.js version, and install Express and the OpenAI SDK:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mkdir storefront-assistant && cd storefront-assistant
npm init -y
npm pkg set type=module scripts.start="node server.js" engines.node="24.x"
npm install express openai
```

OpenRouter speaks the OpenAI Chat Completions API, so the official `openai` package works with it when you change the base URL. Create the server:

```js server.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { createHash, timingSafeEqual } from "node:crypto";
import express from "express";
import OpenAI from "openai";

const token = process.env.APP_TOKEN;
if (!token) throw new Error("APP_TOKEN is not set");

const openrouter = new OpenAI({
  baseURL: "https://openrouter.ai/api/v1",
  apiKey: process.env.OPENROUTER_API_KEY,
});

// OpenRouter tries MODEL first and the fallbacks in order when it fails.
const MODEL = "anthropic/claude-haiku-5.5";
const FALLBACKS = ["openai/gpt-6-luna"];

const app = express();
app.use(express.json());

// Hashing both sides gives equal lengths, which timingSafeEqual needs.
const expected = createHash("sha256").update(`Bearer ${token}`).digest();
function requireToken(req, res, next) {
  const given = createHash("sha256").update(req.headers.authorization ?? "").digest();
  if (timingSafeEqual(given, expected)) return next();
  res.status(401).json({ error: "invalid_token" });
}

app.get("/health", (req, res) => {
  res.send("ok");
});

app.post("/chat", requireToken, async (req, res) => {
  const message = req.body?.message;
  if (typeof message !== "string" || message.length < 1 || message.length > 4000) {
    return res.status(400).json({ error: "message must be 1 to 4000 characters" });
  }

  try {
    const stream = await openrouter.chat.completions.create({
      model: MODEL,
      models: FALLBACKS,
      provider: { data_collection: "deny" },
      reasoning: { effort: "low" },
      messages: [{ role: "user", content: message }],
      max_tokens: 1000,
      stream: true,
    });
    // Stop the generation, and its cost, when the client disconnects.
    res.on("close", () => stream.controller.abort());

    res.type("text/plain").set("Cache-Control", "no-cache");
    for await (const chunk of stream) {
      res.write(chunk.choices[0]?.delta?.content ?? "");
      if (chunk.usage) {
        const { id, model, provider, usage } = chunk;
        const tokens = usage.total_tokens;
        console.log(JSON.stringify({ message: "OpenRouter usage", id, model, provider, tokens, cost: usage.cost }));
      }
    }
    res.end();
  } catch (err) {
    console.error(`OpenRouter error: ${err.message}`);
    if (res.headersSent) return res.end();
    res.status(502).json({ error: "The model request failed" });
  }
});

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`Listening on port ${port}`);
});

process.on("SIGTERM", () => {
  console.log("SIGTERM received, closing the server");
  server.close();
});
```

What the server does:

* **One client for every model.** The OpenAI SDK sends requests to `https://openrouter.ai/api/v1` with `OPENROUTER_API_KEY`. The model is a field of each request, so the same client calls Anthropic, OpenAI and any other model on OpenRouter.
* **A fallback model and a data policy.** OpenRouter answers with `anthropic/claude-haiku-5.5` and, if that model fails, with `openai/gpt-6-luna`. `data_collection: "deny"` keeps requests away from providers that store prompts. Both are explained in [Choose models and providers](#choose-models-and-providers). To use other models, change `MODEL` and `FALLBACKS`.
* **Little reasoning.** Both models reason before they answer unless told otherwise, and reasoning tokens count against `max_tokens` and cost money. `effort: "low"` keeps answers fast and within the 1,000-token limit. See [Reasoning and empty answers](#reasoning-and-empty-answers).
* **Streaming.** `POST /chat` takes `{"message": "..."}` and writes the answer as plain text as it's generated. When the client disconnects, the server aborts the request, and OpenRouter stops the generation and its billing.
* **Cost in the logs.** The last chunk of every stream carries `usage`, with the tokens and the cost in US dollars. The server prints it as one JSON line. See [Track cost](#track-cost).
* **A bearer token.** `requireToken` compares the `Authorization` header with `Bearer ` and `APP_TOKEN` in constant time and answers `401` to anything else, so nobody can spend your credits through the endpoint. The server refuses to start without `APP_TOKEN`. `/health` stays public.
* **Errors.** An OpenRouter error before the answer starts returns `502` to the client and prints the reason, such as `OpenRouter error: 402 ...`, in the logs. The OpenAI SDK retries `429` and `5xx` responses twice first.
* **Shutdown.** Sevalla sends `SIGTERM` to each instance it replaces during a deployment. `server.close()` stops accepting connections and lets streams in progress finish.

Keep installed packages and local secrets out of the repository:

```text .gitignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
node_modules/
.env
```

To run the server on your computer, start it with your key and any local token, then send a message:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
OPENROUTER_API_KEY=sk-or-v1-... APP_TOKEN=local-token npm start
```

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -N http://localhost:3000/chat -H "Authorization: Bearer local-token" \
  -H "Content-Type: application/json" -d '{"message":"Suggest a name for a coffee shop. Answer with the name only."}'
```

The answer streams into the terminal, for example `Daily Grind`.

## Prepare the project for Sevalla

The project already has what [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), Sevalla's builder, needs:

| File or field | Why Sevalla needs it |
| - | - |
| `scripts.start` in `package.json` | Ingot starts the web process with the `start` script, `node server.js`. |
| `engines.node` in `package.json` | Ingot builds on the Node.js 24 line. Without it, Ingot uses its default line, which a new Ingot release can move. See [Pin the Node.js version](https://docs.sevalla.com/guides/javascript/overview.md#pin-the-node-js-version). |
| `package-lock.json` | Ingot installs the exact versions in it with `npm ci`. Commit it. |

The server reads [`PORT`](https://docs.sevalla.com/guides/javascript/overview#listen-on-the-port-environment-variable), which Sevalla sets to the web process's port (`8080` by default), and listens on all interfaces.

Commit everything and push it to your Git provider:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
git init
git add .
git commit -m "Prepare for Sevalla"
git branch -M main
git remote add origin https://github.com/acme/storefront-assistant.git
git push -u origin main
```

## Deploy to Sevalla

These steps build with [Ingot](https://docs.sevalla.com/applications/builds/ingot.md). To build from your own Dockerfile instead, see [Deploy with a Dockerfile](#deploy-with-a-dockerfile). To run them with the Sevalla API, see [Deploy with the API](#deploy-with-the-api).

Generate the token that clients send, and keep it in your password manager:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
openssl rand -hex 32
```

**1. Create the application**

In the sidebar, click **Applications**, then click **Create** > **Application**. Under **Source**, click **Private repository**, then choose the **Git provider**, the **Repository** and the **Branch**. Under **Location**, choose a **Data center**. Keep the **Size** at **H1** or choose a larger one, then click **Create application**. The application opens on its **Overview** tab with the status **Not deployed yet**: creating an application doesn't deploy it.

**2. Add the keys**

Click the **Environment variables** tab, then click **Add environment variables**. Enter `OPENROUTER_API_KEY` as the key and your OpenRouter key as the value. Click **Add another**, then enter `APP_TOKEN` and the token you generated. Under **Availability**, clear **Available during buildtime**, so the keys reach only the running server, then click **Add environment variables**.

**3. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

**4. Turn on auto-deploy**

To deploy every push to the branch automatically, click the **Settings** tab, then click **Update auto-deploy** in the **Auto-deploy** section. Turn on **Auto-deploy** and click **Update auto-deploy**. See [Deployments](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

## Verify the deployment

1. On the **Deployments** tab, click the deployment. Its **Build log** shows Ingot's [build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan): Node.js 24 from `engines.node`, npm from `package-lock.json`, and the start command from the `start` script.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Node.js 24 application ───────────────────────────────────╮
   │ Node.js          24              package.json engines "24.x" │
   │ Package manager  npm             package-lock.json           │
   │ Start            node server.js  package.json scripts.start  │
   ├─ Image ──────────────────────────────────────────────────────┤
   │ Build            node:24-trixie                              │
   │ Runtime          node:24-trixie-slim                         │
   │ User             1000:1000                                   │
   │ Init             tini                                        │
   │ Port             $PORT, default 8080                         │
   ╰─────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

   With the API, read the deployment's status with [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) and its build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs), as in [Deploy with the API](#deploy-with-the-api).

2. Wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**, and its [runtime logs](https://docs.sevalla.com/applications/logs.md) show `Listening on port 8080`.

3. Copy the domain under the application's name, for example `storefront-assistant-x7k2m.sevalla.app`, and check the server. The health check is public:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-assistant-x7k2m.sevalla.app/health
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ok
   ```

   A chat request without the token is refused:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-assistant-x7k2m.sevalla.app/chat \
     -H "Content-Type: application/json" -d '{"message":"Hi"}'
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"error":"invalid_token"}
   ```

   With the token, the answer streams back. Its wording varies from run to run:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   APP_TOKEN=<your token>
   curl -sN https://storefront-assistant-x7k2m.sevalla.app/chat \
     -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
     -d '{"message":"Suggest a name for a coffee shop. Answer with the name only."}'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Daily Grind
   ```

4. On the **Logs** tab, each answer adds a runtime log line `OpenRouter usage`. Click it to see its attributes: `model`, `provider`, `tokens`, `cost` and the generation `id`. See [Track cost](#track-cost).

![The application header with its name, Running status, repository, branch and the domain that opens the application](https://assets.sevalla.com/screenshots/light/app-overview-address.png)

If the deployment fails, read its build log, then see [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds). If the server crashes at start or answers `502`, read the runtime logs and see [Troubleshoot](#troubleshoot).

## Choose models and providers

Each request names the models it may use and how OpenRouter picks a provider to run them. Everything in this section is a field of the request body, so with the OpenAI SDK you add it to `chat.completions.create()`, as `server.js` does.

### Model IDs

A model ID is `provider/model`, for example `anthropic/claude-haiku-5.5` or `openai/gpt-6-luna`. Find IDs and prices on [openrouter.ai/models](https://openrouter.ai/models), or list them with `curl -s https://openrouter.ai/api/v1/models | jq -r '.data[].id'`. OpenRouter's IDs aren't always the provider's own: Anthropic's API calls the same model `claude-haiku-5-5`.

An ID that doesn't exist fails the whole request with `400` and `anthropic/claude-haiku-9 is not a valid model ID`, even when it's only one of the fallbacks.

### Fallback models

`models` lists the models to try, in order, when `model` fails: when its providers are down, rate limited, or refuse the request. OpenRouter bills the model that answered and names it in the `model` field of every chunk, which the server logs. To see each attempt, send the `X-OpenRouter-Metadata: enabled` header. In this request, the first model is a free model that was rate limited at the time:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://openrouter.ai/api/v1/chat/completions \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" -H "Content-Type: application/json" \
  -H "X-OpenRouter-Metadata: enabled" \
  -d '{"model":"google/gemma-4-31b-it:free","models":["anthropic/claude-haiku-5.5"],"max_tokens":20,
       "messages":[{"role":"user","content":"Say hi in 3 words"}]}' \
  | jq -c '{model, attempts: .openrouter_metadata.attempts}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"model":"anthropic/claude-haiku-5.5","attempts":[{"provider":"Google AI Studio","model":"google/gemma-4-31b-it-20260402","status":429},{"provider":"Claude Platform on AWS","model":"anthropic/claude-haiku-5.5-20261007","status":200}]}
```

### Provider preferences

Most models run at several providers. By default, OpenRouter picks one by price, speed and reliability, and retries another provider of the same model when one fails. The `provider` object changes that:

| Field | What it does | Default |
| - | - | - |
| `order` | Provider slugs to try first, in order, for example `["amazon-bedrock", "anthropic"]`. The slugs are on each model's page on openrouter.ai | OpenRouter's choice |
| `allow_fallbacks` | `false` uses only the providers in `order`, and fails when none of them can serve the model | `true` |
| `data_collection` | `"deny"` uses only providers that don't store prompts or train on them | `"allow"` |

For example, `"provider": {"order": ["amazon-bedrock", "anthropic"]}` sent `anthropic/claude-haiku-5.5` to Amazon Bedrock. With `"order": ["deepinfra"], "allow_fallbacks": false`, a provider that doesn't host the model, the request failed with `404` and `No endpoints found for anthropic/claude-haiku-5.5. Every candidate endpoint was removed during routing`. To apply a data policy to every request of every key, set it in your OpenRouter [privacy settings](https://openrouter.ai/settings/privacy) instead.

### Use free models

A model ID that ends in `:free`, such as `google/gemma-4-31b-it:free`, is a free variant: it costs nothing, and its limits are low. OpenRouter documents these [limits](https://openrouter.ai/docs/api_reference/limits) for free variants:

| Credits bought on the account, all time | Requests per minute | Requests per day |
| - | - | - |
| Less than 10 | 20 | 50 |
| 10 or more | 20 | 1,000 |

`GET /api/v1/key` reports the daily count in `free_model_daily_requests`, for example `{"used":24,"limit":50,"remaining":26}`. Free models also run on shared capacity at their providers, so they're often rate limited regardless of your own count. The request then fails with `429` and `Provider returned error`, and the error's `metadata.raw` says `google/gemma-4-31b-it:free is temporarily rate-limited upstream. Please retry shortly, or add your own key to accumulate your rate limits`.

Free models suit tests and side projects, not a service your customers rely on. A paid model in `models` after a free one makes the request reliable but no longer free: OpenRouter bills the fallback at its own price.

### Reasoning and empty answers

`anthropic/claude-haiku-5.5` and `openai/gpt-6-luna` reason at medium effort unless the request says otherwise. Reasoning tokens are billed as output and count against `max_tokens`, and the answer starts only after them. In a test without `reasoning`, a request for a 250-word text spent 999 of its 1,000 tokens reasoning and returned no text, with `finish_reason` `length`. With `reasoning: { effort: "low" }`, the first words arrived after about 0.7 seconds. Set `effort` to `"none"` to turn reasoning off, or raise `max_tokens` for models that need to think. Each model's `reasoning` object in `GET /api/v1/models` lists the efforts it supports.

### Attribution headers

Requests that carry an `HTTP-Referer` header with your site's URL count toward your app in OpenRouter's public [rankings](https://openrouter.ai/rankings) and its analytics. `X-OpenRouter-Title` sets the app's display name (`X-Title` still works). Add them to the client:

```js server.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
const openrouter = new OpenAI({
  baseURL: "https://openrouter.ai/api/v1",
  apiKey: process.env.OPENROUTER_API_KEY,
  defaultHeaders: {
    "HTTP-Referer": "https://shop.example.com",
    "X-OpenRouter-Title": "Storefront assistant",
  },
});
```

The first request with a new `HTTP-Referer` creates a public app page on OpenRouter. To keep the app out of the public rankings, add `"X-OpenRouter-App-Visibility": "hidden"` to the first request that creates it; OpenRouter ignores the header for apps that already exist.

## Stream responses through Sevalla

Streamed answers reach the client unbuffered through Cloudflare and Sevalla's proxy: no proxy setting or extra header is needed. The server sends `Content-Type: text/plain; charset=utf-8` and `Cache-Control: no-cache`, and writes each piece of text as OpenRouter sends it. To compare the time to the first byte with the total time, discard the body and print curl's timings:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -sN -o /dev/null -w 'first byte %{time_starttransfer}s, total %{time_total}s\n' \
  https://storefront-assistant-x7k2m.sevalla.app/chat \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d '{"message":"Write a 250-word history of espresso."}'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
first byte 0.825196s, total 4.093238s
```

On an **H1** web process, the first words of a 250-word answer arrived after 0.68 to 0.96 seconds, and the rest over the next 3 seconds in 43 to 53 chunks, about as fast as from the same server on a laptop.

### What OpenRouter streams

Under the SDK, OpenRouter streams Server-Sent Events. While a model works before its first token, OpenRouter sends a comment line about every 0.4 seconds to keep the connection open. In a raw stream from `curl -N` against OpenRouter, with the JSON cut short:

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
: OPENROUTER PROCESSING

: OPENROUTER PROCESSING

data: {"id":"gen-1793334100-CECM0rCMsaCiMNBwR2mJ","object":"chat.completion.chunk",...}

data: [DONE]
```

* The OpenAI SDK and the AI SDK skip the comment lines. If you parse the stream yourself, skip every line that starts with `:` before you call `JSON.parse`, which throws on them.
* The last chunk before `data: [DONE]` carries `usage`. The server reads its cost from there.
* An error after the stream has started arrives as a `data:` event with an `error` object and `finish_reason` `"error"`, because the `200` status was already sent. The OpenAI SDK throws it as an error, and the server ends the response.

### Long requests

Sevalla's proxy closes a request that sends nothing for too long, see [504 Gateway Timeout and Cloudflare error 524](https://docs.sevalla.com/applications/troubleshooting/http-errors.md#504-gateway-timeout-and-cloudflare-error-524). The comment lines keep OpenRouter's connection to your server open, but they don't reach your client: `server.js` forwards only text. A model that reasons for minutes before it writes sends your client nothing in that time, so keep reasoning low for requests that answer a person, and run long jobs in a [background worker](https://docs.sevalla.com/applications/processes.md#background-workers) that stores the result.

## Track cost

Every OpenRouter response includes `usage`: the prompt, completion and reasoning tokens, and `cost`, what the request cost in US dollars. You don't need to ask for it.

### Read cost in the runtime logs

`server.js` prints one JSON line per answer. Sevalla reads a runtime log line that is a JSON object as attributes: the line shows its `message`, `OpenRouter usage`, and the other keys become attributes. On the **Logs** tab, click a line to see them, or filter with **Add filter** on a key such as `attributes.model`, for example to find answers that came from the fallback model. See [Filter by attribute](https://docs.sevalla.com/applications/logs.md#filter-by-attribute). With the API:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "https://api.sevalla.com/v3/applications/$APP_ID/runtime-logs?search=OpenRouter%20usage" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -c '.[] | {message, model: .attributes.model, cost: .attributes.cost, tokens: .attributes.tokens}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"message":"OpenRouter usage","model":"anthropic/claude-haiku-5.5","cost":"0.0002656","tokens":"548"}
```

The attribute values are strings. A 250-word answer from `anthropic/claude-haiku-5.5` cost about \$0.0003.

### Look up a generation

Each answer has a generation ID, the `id` in the log line. OpenRouter's [generation endpoint](https://openrouter.ai/docs/api/api-reference/generations/get-request-&-usage-metadata-for-a-generation) returns its details a few seconds after it finishes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "https://openrouter.ai/api/v1/generation?id=gen-1793336450-T6OpwndveCI5PdUxeZkg" \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" \
  | jq -c '.data | {model, provider_name, tokens_prompt, tokens_completion, total_cost, latency, generation_time, cancelled}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"model":"anthropic/claude-haiku-5.5-20261007","provider_name":"Claude Platform on AWS","tokens_prompt":10,"tokens_completion":400,"total_cost":0.0002656,"latency":420,"generation_time":3425,"cancelled":false}
```

`latency` is the time to the first token and `generation_time` the whole generation, both in milliseconds. `provider_responses` lists every attempt, including failed fallbacks. When a client disconnected after 1.5 seconds, the record showed `"cancelled":true` and 63 completion tokens billed instead of a full answer.

### Cap spending

* **One key per application**, each with a credit limit, as in [Create an OpenRouter API key](#create-an-openrouter-api-key). `GET /api/v1/key` shows what the key has spent and has left.
* **`max_tokens` on every request**, so one request can't run long. OpenRouter also checks before it starts that your balance covers the request's largest possible cost.
* **Authentication on every endpoint that calls a model.** An open `/chat` lets anyone spend your credits.

## Use the AI SDK

The [AI SDK](https://ai-sdk.dev) has an official OpenRouter provider, `@openrouter/ai-sdk-provider`, maintained by OpenRouter. Version 3 works with AI SDK 7 and Node.js 22 or later. In the same project, replace the OpenAI SDK:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm uninstall openai
npm install ai @openrouter/ai-sdk-provider
```

In `server.js`, replace the `openai` import, the client and the `MODEL` and `FALLBACKS` constants with the provider and a model, and the `/chat` route with one that uses `streamText`. The rest of the file stays the same:

```js server.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { pipeTextStreamToResponse, streamText } from "ai";

// createOpenRouter reads the key from OPENROUTER_API_KEY.
const openrouter = createOpenRouter();
const model = openrouter("anthropic/claude-haiku-5.5", {
  models: ["openai/gpt-6-luna"],
  provider: { data_collection: "deny" },
  reasoning: { effort: "low" },
});

app.post("/chat", requireToken, (req, res) => {
  const message = req.body?.message;
  if (typeof message !== "string" || message.length < 1 || message.length > 4000) {
    return res.status(400).json({ error: "message must be 1 to 4000 characters" });
  }

  // Stop the generation, and its cost, when the client disconnects.
  const controller = new AbortController();
  res.on("close", () => controller.abort());

  const result = streamText({
    model,
    prompt: message,
    maxOutputTokens: 1000,
    abortSignal: controller.signal,
    onEnd: ({ response, providerMetadata }) => {
      const cost = providerMetadata?.openrouter?.usage?.cost;
      console.log(JSON.stringify({ message: "OpenRouter usage", id: response.id, model: response.modelId, cost }));
    },
    onError: ({ error }) => console.error(`OpenRouter error: ${error.message}`),
  });
  pipeTextStreamToResponse({
    response: res,
    headers: { "Cache-Control": "no-cache" },
    stream: result.textStream,
  });
});
```

The server behaves as before and streams the same way through Sevalla. The model settings take the same `models`, `provider` and `reasoning` fields as the request body, and the cost is in `providerMetadata.openrouter.usage.cost`. One difference: `pipeTextStreamToResponse` sends `200` before OpenRouter answers, so when a request fails, for example with an unknown model ID, the client gets `200` with an empty body, and the reason is in the runtime logs, such as `OpenRouter error: anthropic/claude-haiku-9 is not a valid model ID`.

## Use OpenRouter in other AI guides

OpenRouter can be the model provider of the other applications in these guides. Each needs only the OpenRouter key, as an environment variable of that application with **Available during buildtime** cleared, and the model IDs.

### LiteLLM

In the [LiteLLM proxy](https://docs.sevalla.com/guides/ai/litellm.md), prefix an OpenRouter model ID with `openrouter/` and pass the key. Add the models to `model_list` in `config.yaml`, next to the models from [Add Anthropic and OpenAI models](https://docs.sevalla.com/guides/ai/litellm.md#add-anthropic-and-openai-models):

```yaml config.yaml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  - model_name: claude-haiku-5.5
    litellm_params:
      model: openrouter/anthropic/claude-haiku-5.5
      api_key: os.environ/OPENROUTER_API_KEY
      models: ["openai/gpt-6-luna"]
      extra_body:
        provider:
          data_collection: deny
```

Add `OPENROUTER_API_KEY` to the `litellm` application's environment variables, then commit, push and deploy. Your applications call `claude-haiku-5.5` through LiteLLM with their virtual keys. LiteLLM passes `models` to OpenRouter as fallbacks and `extra_body` as it is, and records the cost OpenRouter reports for each request: a chat through `ghcr.io/berriai/litellm-non_root:v1.106.6` returned `x-litellm-response-cost: 1.97e-05`, the same as OpenRouter's `usage.cost`.

### Open WebUI

[Open WebUI](https://docs.sevalla.com/guides/ai/open-webui.md) connects to OpenRouter as an OpenAI-compatible connection. In Open WebUI, go to **Admin Panel** > **Settings** > **Connections**. Next to **Manage OpenAI API Connections**, click **+**. In **Add Connection**, enter the **URL** `https://openrouter.ai/api/v1` and your OpenRouter key as the **API Key**. Under **Advanced**, add the models you offer in **Model IDs**, such as `anthropic/claude-haiku-5.5` and `openai/gpt-6-luna`, then click **Save**. With **Model IDs** empty, the model picker lists every model on OpenRouter.

For a new Open WebUI whose only connection is OpenRouter, set the connection with environment variables before the first start instead:

| Variable | Value |
| - | - |
| `OPENAI_API_BASE_URL` | `https://openrouter.ai/api/v1` |
| `OPENAI_API_KEY` | Your OpenRouter key |
| `OPENAI_API_CONFIGS` | `{"0":{"model_ids":["anthropic/claude-haiku-5.5","openai/gpt-6-luna"]}}` |

Open WebUI `v0.12.5-slim` with these variables listed the two models and streamed their answers. Open WebUI reads them only on its first start; see [Change settings after the first start](https://docs.sevalla.com/guides/ai/open-webui.md#change-settings-after-the-first-start).

### n8n

n8n has its own **OpenRouter Chat Model** node. In a workflow, add it as the chat model of an **AI Agent** or **Basic LLM Chain** node. In the node, create an **OpenRouter** credential with your key as the **API Key**. Set **Model** to an ID such as `anthropic/claude-haiku-5.5`. Under **Options**, you can set **Reasoning Effort**, **Maximum Number of Tokens**, and **Provider Routing** with **Order**, **Allow Fallbacks** and **Data Collection**. In n8n 2.45.6, a webhook workflow with an **AI Agent** and this node answered `{"output":"Daily Grind"}`. See [Deploy n8n](https://docs.sevalla.com/guides/ai/n8n.md).

### AI SDK applications

Any application built with the AI SDK, such as the [AI SDK chatbot](https://docs.sevalla.com/guides/ai/ai-sdk-chatbot.md), switches to OpenRouter by swapping its provider for `createOpenRouter()` and its model ID for an OpenRouter ID, as in [Use the AI SDK](#use-the-ai-sdk). Replace the provider's key variable with `OPENROUTER_API_KEY`.

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the system packages and every layer. Add a `Dockerfile` and a `.dockerignore` to the root of the repository:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM node:24-trixie-slim

WORKDIR /app
ENV NODE_ENV=production PORT=8080

COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY . .

USER 1000:1000
EXPOSE 8080
CMD ["node", "server.js"]
```

| Line or choice | Why |
| - | - |
| `node:24-trixie-slim` | The official Node.js 24 image on Debian 13, the line that `engines.node` pins for Ingot. The tag follows Node.js 24's patch releases, so each build gets its security fixes; a digest would stay on one release. |
| One stage | Nothing is compiled, so the image needs no build stage. `npm ci --omit=dev` installs the exact versions in the lock file. |
| `package.json` and `package-lock.json` first | Docker reuses the installed dependencies when only the code changes. |
| `USER 1000:1000` | The server runs as the image's unprivileged `node` user. The code and dependencies stay owned by root, so the server can read them but not change them. |
| No init process | `server.js` handles `SIGTERM` itself, so Node.js shuts down cleanly as the container's first process. |
| `PORT=8080` and `EXPOSE 8080` | The server listens on `PORT`, which Sevalla sets at runtime. The image's default matches Sevalla's, for running it on your computer. |

The image holds no secrets: `OPENROUTER_API_KEY` and `APP_TOKEN` reach the container at runtime.

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
node_modules
npm-debug.log*
.env*
Dockerfile
.dockerignore
```

Commit and push both files. Ingot doesn't use the `Dockerfile`, so you can keep it in a repository that builds with Ingot, but it adds the `.dockerignore` patterns to its own: its build plan shows an **Ignored** row with `6 patterns from .dockerignore`. Then switch the application to the Dockerfile build strategy:

**1. Choose the Dockerfile build strategy**

On the application's **Settings** tab, in the **Build strategy** section, click **Update build strategy**. Click **Dockerfile**, keep the **Dockerfile path** `Dockerfile` and the **Docker context** `.`, then click **Update build strategy**.

**2. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

![The Update build strategy sheet with Dockerfile selected, the Dockerfile path, the Docker context and the Docker registry credential](https://assets.sevalla.com/screenshots/light/app-settings-build-strategy-dockerfile.png)

The deployment's **Build log** shows `📦 Building and pushing docker image using Dockerfile...` instead of Ingot's build plan, and its **Dockerfile** tab shows your `Dockerfile`. When the deployment is **Deployed**, the `curl` requests in [Verify the deployment](#verify-the-deployment) return the same results. With the API, the same switch is [Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) with `{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}`, followed by a deployment. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md) for build arguments and private base images.

## Deploy changes

With **Auto-deploy** on, every push to the branch starts a deployment; otherwise, click **Deploy now** for each change. Each deployment builds the image, then replaces the web process's instances. Changing a model is a change to `MODEL` or `FALLBACKS` in `server.js`, followed by a deployment. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Add a custom domain

To serve the endpoint from your own domain, such as `assistant.example.com`, add the domain on the application's **Domains** tab and create the DNS records that Sevalla shows. Custom domains need a web process larger than **H1**, so [scale](https://docs.sevalla.com/applications/scaling.md) the web process first. See [Custom domains](https://docs.sevalla.com/domains/overview.md).

## Troubleshoot

The server prints OpenRouter's errors in the runtime logs as `OpenRouter error: <status> <message>`.

| Symptom | Cause | Fix |
| - | - | - |
| The web process crashes with `Error: APP_TOKEN is not set` | The application has no `APP_TOKEN` variable, or it's available only during the build. | Add `APP_TOKEN` with **Available during runtime** selected, then deploy. |
| The web process crashes with `OpenAIError: Missing credentials`, and the message names `OPENAI_API_KEY` | `OPENROUTER_API_KEY` isn't set, so the OpenAI SDK has no key. | Add `OPENROUTER_API_KEY` with **Available during runtime** selected, then deploy. |
| Requests get `401` and `{"error":"invalid_token"}` | The client sends no `Authorization` header, or a different token. | Send `Authorization: Bearer <token>` with the value of `APP_TOKEN`. |
| Requests get `502`, and the logs show `OpenRouter error: 401 User not found.` | The OpenRouter key is wrong or deleted. | Copy the key again, or create one, then update `OPENROUTER_API_KEY` and deploy. |
| The logs show `OpenRouter error: 400 anthropic/claude-haiku-9 is not a valid model ID` | A model ID in `MODEL` or `FALLBACKS` doesn't exist on OpenRouter. | Copy the ID from [openrouter.ai/models](https://openrouter.ai/models). OpenRouter's IDs can differ from the provider's own. |
| The logs show `OpenRouter error: 402 This request requires more credits, or fewer max_tokens. You requested up to 128000 tokens, but can only afford 103998.` | The account's balance, or the key's credit limit, doesn't cover the request's largest possible cost. | Add credits, raise the key's limit, or lower `max_tokens`. |
| The logs show `OpenRouter error: 429 Provider returned error` | A free model is rate limited at its provider, or the account reached its free-model limit. | Retry later, add a paid model to `FALLBACKS`, or use the paid model. See [Use free models](#use-free-models). |
| A request fails with `404` and `No endpoints found for anthropic/claude-haiku-5.5` | `provider` settings exclude every provider of the model, for example `order` with `allow_fallbacks: false`. | Add a provider that hosts the model to `order`, or remove `allow_fallbacks: false`. |
| The answer is empty, and the generation shows `finish_reason` `length` | The model spent `max_tokens` on reasoning. | Lower `reasoning.effort` or raise `max_tokens`. See [Reasoning and empty answers](#reasoning-and-empty-answers). |

For build and runtime failures every application shares, see [Troubleshoot builds and deployments](https://docs.sevalla.com/applications/troubleshooting/builds-and-deployments.md).

## Deploy with the API

These steps create and deploy the same application with the [Sevalla API](https://api-docs.sevalla.com), as an AI agent or a script would. Each step names the [Sevalla CLI](https://docs.sevalla.com/developer-tools/cli.md) command and the [Sevalla MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool that do the same. You need:

* A Sevalla [API key](https://docs.sevalla.com/developer-tools/api-keys.md) that can create and update applications and deployments, in `SEVALLA_API_KEY`.
* The repository on a Git provider connected to your Sevalla company. If Sevalla can't read the repository, [Create application](https://api-docs.sevalla.com/v3/applications/create-application) fails with `400` and `Cannot access repository <url>: Repository not found or you don't have access.`, followed by where to grant access. [List Git providers](https://api-docs.sevalla.com/v3/git/list-git-providers) (`listGitProvidersV3`) returns each provider's `connect_url`, and for GitHub a `settings_url` for repository access.
* `OPENROUTER_API_KEY` and `APP_TOKEN` in the shell, and `curl` and `jq`.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
API=https://api.sevalla.com/v3
AUTH="Authorization: Bearer $SEVALLA_API_KEY"
```

**1. Pick the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location. Pick one by its `location`, for example Frankfurt, Germany (`eu-frankfurt-1`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "$API/resources/clusters" -H "$AUTH" | jq -r '.[] | "\(.location)  \(.display_name)"'
CLUSTER_ID=$(curl -s "$API/resources/clusters" -H "$AUTH" | jq -r '.[] | select(.location == "eu-frankfurt-1") | .id')
```

CLI: `sevalla resources clusters`. MCP: `get_platform_options`.

**2. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates the application with a web process and doesn't deploy it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s -X POST "$API/applications" -H "$AUTH" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" '{
    display_name: "storefront-assistant", cluster_id: $cluster, source: "privateGit", git_type: "github",
    repo_url: "https://github.com/acme/storefront-assistant", default_branch: "main"}')" \
  | jq -r .id)
echo "$APP_ID"
```

CLI: `sevalla apps create --name storefront-assistant --cluster "$CLUSTER_ID" --source privateGit --git-type github --repository https://github.com/acme/storefront-assistant --branch main`. MCP: `create_application` with `deploy: false`.

**3. Set the size to H1**

The API creates the web process at **S1**, not the dashboard's **H1**. [List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) and [List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) give the IDs, and [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets the size:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s "$API/applications/$APP_ID/processes" -H "$AUTH" | jq -r '.data[] | select(.type == "web") | .id')
H1_ID=$(curl -s "$API/resources/process-resource-types" -H "$AUTH" | jq -r '.[] | select(.name == "h1") | .id')
curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" -H "$AUTH" -H "Content-Type: application/json" \
  -d "{\"resource_type_id\":\"$H1_ID\"}" | jq -r .resource_type_name
```

The output is `h1`. CLI: `sevalla apps processes update "$WEB_ID" --app-id "$APP_ID" --resource-type "$H1_ID"`; the CLI's `--json` lists are arrays, so select with `.[]` instead of `.data[]`. MCP: `scale_process` with `size_id` and `apply_now: false`.

**4. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable, available during runtime only. The response includes the value, so the commands print only the key:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/applications/$APP_ID/env-vars" -H "$AUTH" -H "Content-Type: application/json" \
  -d "$(jq -n --arg value "$OPENROUTER_API_KEY" '{key: "OPENROUTER_API_KEY", value: $value, is_runtime: true, is_buildtime: false}')" \
  | jq -r .key
curl -s -X POST "$API/applications/$APP_ID/env-vars" -H "$AUTH" -H "Content-Type: application/json" \
  -d "$(jq -n --arg value "$APP_TOKEN" '{key: "APP_TOKEN", value: $value, is_runtime: true, is_buildtime: false}')" \
  | jq -r .key
```

CLI: `sevalla apps env-vars create "$APP_ID" --key APP_TOKEN --value "$APP_TOKEN"` makes the variable available during build and runtime, and has no option for runtime only, so use the API or MCP for keys. MCP: `set_environment_variables` with `scope: "application"`, and `runtime: true`, `buildtime: false` on each variable.

**5. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) deploys the latest commit on the default branch:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" -H "$AUTH" \
  -H "Content-Type: application/json" -d '{}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger "$APP_ID"`. MCP: `trigger_deployment`.

**6. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) reports `waiting`, `inProgress`, then `success` or `failed`. Poll it, then read the build plan with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
while true; do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$AUTH" | jq -r .status)
  echo "$STATUS"
  case "$STATUS" in success|failed|cancelled) break ;; esac
  sleep 10
done
curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?limit=5000" -H "$AUTH" | jq -r '.[].message'
```

The loop ends with `success`, and the log contains the build plan from [Verify the deployment](#verify-the-deployment). CLI: `sevalla apps deployments get "$DEPLOYMENT_ID" --app-id "$APP_ID"` and `sevalla apps deployments logs "$DEPLOYMENT_ID" --app-id "$APP_ID"`. MCP: `get_deployment` and `get_deployment_logs`.

**7. Check the application**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the domain:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
HOST=$(curl -s "$API/applications/$APP_ID/domains" -H "$AUTH" | jq -r '.data[] | select(.is_primary) | .name')
curl -s "https://$HOST/health"
curl -sN "https://$HOST/chat" -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d '{"message":"Suggest a name for a coffee shop. Answer with the name only."}'
```

The first request prints `ok` and the second a name. Then [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`) with `search=OpenRouter%20usage` returns the usage line, as in [Read cost in the runtime logs](#read-cost-in-the-runtime-logs). CLI: `sevalla apps domains list --app-id "$APP_ID"` and `sevalla apps logs runtime "$APP_ID" --search "OpenRouter usage"`. MCP: `sevalla_api_read` with `listApplicationDomainsV3`, and `search_runtime_logs`.

**8. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) with `auto_deploy`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH "$API/applications/$APP_ID" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"auto_deploy":true}' | jq -r .auto_deploy
```

The output is `true`. CLI: `sevalla apps update "$APP_ID" --data '{"auto_deploy":true}'`. MCP: `sevalla_api_update` with `updateApplicationV3` and the same body.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.