Skip to main content
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

Environment variables

Before you begin

  • A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See Git providers.
  • Node.js 24 or later on your computer.
  • An OpenRouter account. Paid models need credits on the account; 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 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:
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:
OpenRouter speaks the OpenAI Chat Completions API, so the official openai package works with it when you change the base URL. Create the server:
server.js
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. 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.
  • 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.
  • 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:
.gitignore
To run the server on your computer, start it with your key and any local token, then send a message:
The answer streams into the terminal, for example Daily Grind.

Prepare the project for Sevalla

The project already has what Ingot, Sevalla’s builder, needs: The server reads PORT, 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:

Deploy to Sevalla

These steps build with Ingot. To build from your own Dockerfile instead, see Deploy with a Dockerfile. To run them with the Sevalla API, see Deploy with the API. Generate the token that clients send, and keep it in your password manager:
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.

Verify the deployment

  1. On the Deployments tab, click the deployment. Its Build log shows Ingot’s build plan: Node.js 24 from engines.node, npm from package-lock.json, and the start command from the start script.
    Build log
    With the API, read the deployment’s status with Get deployment and its build log with Get application deployment logs, as in 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 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:
    A chat request without the token is refused:
    With the token, the answer streams back. Its wording varies from run to run:
  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.
The application header with its name, Running status, repository, branch and the domain that opens the applicationThe application header with its name, Running status, repository, branch and the domain that opens the application
If the deployment fails, read its build log, then see Troubleshoot Ingot builds. If the server crashes at start or answers 502, read the runtime logs and see 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, 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:

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: 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 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 for free variants: 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 and its analytics. X-OpenRouter-Title sets the app’s display name (X-Title still works). Add them to the client:
server.js
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:
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:
  • 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. 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 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. With the API:
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 returns its details a few seconds after it finishes:
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. 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 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:
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:
server.js
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, 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:
config.yaml
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 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: 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.

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.

AI SDK applications

Any application built with the AI SDK, such as the AI SDK chatbot, switches to OpenRouter by swapping its provider for createOpenRouter() and its model ID for an OpenRouter ID, as in 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
The image holds no secrets: OPENROUTER_API_KEY and APP_TOKEN reach the container at runtime.
.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 credentialThe Update build strategy sheet with Dockerfile selected, the Dockerfile path, the Docker context and the Docker registry credential
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 return the same results. With the API, the same switch is Update application (updateApplicationV3) with {"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}, followed by a deployment. See Dockerfile 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.

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 the web process first. See Custom domains.

Troubleshoot

The server prints OpenRouter’s errors in the runtime logs as OpenRouter error: <status> <message>. For build and runtime failures every application shares, see Troubleshoot builds and deployments.

Deploy with the API

These steps create and deploy the same application with the Sevalla API, as an AI agent or a script would. Each step names the Sevalla CLI command and the Sevalla MCP server tool that do the same. You need:
  • A Sevalla API key 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 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 (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.
1

Pick the location

List clusters (listClustersV3) returns every location. Pick one by its location, for example Frankfurt, Germany (eu-frankfurt-1):
CLI: sevalla resources clusters. MCP: get_platform_options.
2

Create the application

Create application (createApplicationV3) creates the application with a web process and doesn’t deploy it:
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 (listProcessesV3) and List process resource types (listProcessResourceTypesV3) give the IDs, and Update process (updateProcessV3) sets the size:
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 (createEnvironmentVariableV3), once per variable, available during runtime only. The response includes the value, so the commands print only the 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 (triggerDeploymentV3) deploys the latest commit on the default branch:
CLI: sevalla apps deployments trigger "$APP_ID". MCP: trigger_deployment.
6

Wait for the deployment

Get deployment (getDeploymentV3) reports waiting, inProgress, then success or failed. Poll it, then read the build plan with Get application deployment logs (getApplicationDeploymentLogsV3):
The loop ends with success, and the log contains the build plan from 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 (listApplicationDomainsV3) returns the domain:
The first request prints ok and the second a name. Then Get application runtime logs (getApplicationRuntimeLogsV3) with search=OpenRouter%20usage returns the usage line, as in 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 (updateApplicationV3) with 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.