Skip to content
docs/migrate-openrouterGuides

Migrate from OpenRouter

Change a base URL and a key. The shapes are the same ones.

/v1/chat/completions, /v1/completions, /v1/models, /v1/key, /v1/credits and /v1/generation answer in OpenRouter's shapes, under OpenRouter's field names. For most integrations the migration is two lines. Everything this gateway adds is additive (extra keys on the same objects), so a client that ignores what it does not recognise will never see them.

DIFFThe whole change, for most integrations
 const client = new OpenAI({
-  baseURL: "https://openrouter.ai/api/v1",
-  apiKey: process.env.OPENROUTER_API_KEY,
+  baseURL: "https://api.routehook.ai/v1",
+  apiKey: process.env.ROUTEHOOK_API_KEY,
 });

Identical, and needs no edit

  • The request body of /v1/chat/completions and /v1/completions, including models, stream, tools, response_format, provider routing and every sampling parameter.
  • The streaming framing: data: {…} frames, terminated by data: [DONE].
  • usage, with prompt_tokens, completion_tokens and total_tokens.
  • The Authorization: Bearer header, and unknown body fields being forwarded upstream rather than refused.
  • The request and response of /v1/responses, /v1/embeddings, /v1/images, /v1/videos, /v1/audio/speech, /v1/audio/transcriptions and /v1/rerank.

Route map

OPENROUTERHEREWHAT CHANGES
POST /api/v1/chat/completionsPOST /v1/chat/completionsNothing. Same body, same response, same SSE framing.
POST /api/v1/completionsPOST /v1/completionsNothing on the wire. The prompt is wrapped as one user message upstream and unwrapped back to choices[].text.
GET /api/v1/modelsGET /v1/modelsSame shape. The ids are this catalogue's slugs. Check them, do not assume them.
GET /api/v1/keyGET /v1/keyTheir seven fields, plus key_id, account_id and timestamps.
GET /api/v1/creditsGET /v1/creditstotal_credits and total_usage unchanged; balance, held and available added.
GET /api/v1/generation?id=GET /v1/generation?id=Same field names, plus reference_value. Neither provider_cost nor provider_name is published.
GET /api/v1/providers-No equivalent. There is no published provider directory here.
POST /api/v1/auth/keys-No OAuth exchange. Keys are issued in the dashboard.
GET, POST /api/v1/keys-No key-provisioning API. is_provisioning_key is always false.
GET /api/v1/models/userGET /v1/models/userAnswers, but returns the full catalogue: there are no per-key model allowances here to narrow it by.
GET /api/v1/models/countGET /v1/models/countSame shape.
GET /api/v1/model/:author/:slugGET /v1/model/{author}/{slug}Same shape. Singular `model`: their spelling for this route, kept.
GET /api/v1/embeddings/modelsGET /v1/embeddings/modelsSame shape, with total_count. Likewise /v1/images/models and /v1/videos/models.
POST /api/v1/responsesPOST /v1/responsesSame body and same output[] / output_text response. Served by every text model here, not one vendor's.
POST /api/v1/embeddingsPOST /v1/embeddingsNothing. Same body, same list response.
POST /api/v1/imagesPOST /v1/imagesSame body. /v1/images/generations is the same handler under OpenAI's path.
POST /api/v1/videosPOST /v1/videosSame body. Poll with GET /v1/videos/{job_id} and fetch bytes from /content.
POST /api/v1/audio/speechPOST /v1/audio/speechSame body. Answers with the audio bytes and the vendor's content type.
POST /api/v1/audio/transcriptionsPOST /v1/audio/transcriptionsThe JSON form, with the audio base64 under input_audio.
POST /api/v1/rerankPOST /v1/rerankSame body. Needs a rerank model in the catalogue before it serves. See Reranking.

The base URL

https://openrouter.ai/api/v1 becomes https://api.routehook.ai/v1. The /api segment goes and the /v1 stays. An OpenAI client appends the route to whatever base URL it is given, so that is the string it wants verbatim. An Anthropic client is the one exception, and /docs/sdks says what it wants instead.

The key

An OpenRouter key is sk-or-v1-…; here it is sk_live_…. The header does not change. There is no test key and no sandbox (every key spends the balance), so cap what a new one may spend and read GET /v1/key to assert which key a deploy picked up, rather than finding out from an invoice.

Attribution headers

HTTP-Referer and X-Title do not have to be removed. HTTP-Referer is recorded as origin on the request record and comes back on GET /v1/generation. X-Title is accepted and then ignored. There is no public application leaderboard here to be ranked on, so the header has nowhere to go.

Model ids

Slugs are vendor/model, and many are spelt exactly as OpenRouter spells them, but they are this catalogue's ids, not a mirror of theirs. Resolve every id you send today against GET /v1/models before cutting over. An id that does not exist here answers 409 model_unavailable, which is a clean failure rather than a silent substitution.

Variant suffixes

OPENROUTERHERE
model:nitroThe model id on its own. There is no per-request sort. Routing is ours.
model:floorThe model id on its own. There is no per-request sort. Routing is ours.
model:freeNothing. There is no free tier, and is_free_tier is always false.

Fallback models

OpenRouter's models array is here and behaves the same way: up to eight alternatives, tried in order, and only once every attempt at the preceding model has been exhausted. It changes which *model* answers, which is the caller's decision and never the operator's, so nothing is tried unless you send it.

JSONA request with its own fallback list
{
  "model": "openai/gpt-4o-mini",
  "models": ["anthropic/claude-sonnet-4"],
  "messages": [{ "role": "user", "content": "Hello" }]
}

The configured circuit breaker still removes unhealthy targets automatically. Your block is applied on top: only and ignore narrow the chain, order and sort reorder it, allow_fallbacks: false pins the first eligible target, and require_parameters removes targets that cannot honour fields in this request.

Before

JAVASCRIPT
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://openrouter.ai/api/v1",
  apiKey: process.env.OPENROUTER_API_KEY,
  defaultHeaders: {
    "HTTP-Referer": "https://your.app",
    "X-Title": "Your App",
  },
});

const completion = await client.chat.completions.create({
  model: "openai/gpt-4o-mini",
  models: ["anthropic/claude-sonnet-4"],
  provider: { sort: "throughput" },
  messages: [{ role: "user", content: "Hello" }],
});

console.log(completion.choices[0].message.content);

After

JAVASCRIPT
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.routehook.ai/v1",
  apiKey: process.env.ROUTEHOOK_API_KEY,
  defaultHeaders: {
    "HTTP-Referer": "https://your.app",
    "X-Title": "Your App",
  },
});

const completion = await client.chat.completions.create({
  model: "openai/gpt-4o-mini",
  models: ["anthropic/claude-sonnet-4"],
  messages: [{ role: "user", content: "Hello" }],
});

console.log(completion.choices[0].message.content);

What is not here

  • No key provisioning. There is no /keys API and no OAuth exchange. Keys are created in the dashboard, and is_provisioning_key on GET /v1/key is always false.
  • No per-key model allowances. Every key reaches the whole catalogue, so there is no filtered model list to migrate and nothing to grant.
  • No free tier. is_free_tier is always false, and a :free id resolves to no model at all.
  • Transforms and plugins are not implemented yet. A non-empty request is rejected explicitly instead of being silently changed. Trim long prompts yourself and remove plugin configuration before migrating.
  • No bring-your-own-key. There is no route to attach your own upstream credentials and be billed by that vendor instead of by us.

What you gain

  • POST /v1/embeddings and POST /v1/images/generations, neither of which OpenRouter serves.
  • POST /v1/messages, so an Anthropic client reaches the same account and the same balance.
  • The queue (POST /v1/queue/{model} and the routes that follow it) for video and anything else that outlives an HTTP request.
  • POST /v1/mcp: the catalogue, the gateway and the queue as MCP tools.
  • reference_value on every generation record. What the same call costs at the vendor's list price, measured per request rather than asserted per month.

Cutting over

  1. 01
    Issue a key for the migration

    Give the cutover its own key with a spend cap, rather than reusing one already in production. It is obvious in a log, cheap to revoke, and the cap bounds what a mistranslated request can cost while you are still checking the mapping.

  2. 02
    Resolve every model id

    Read GET /v1/models and map each id your code sends today onto a slug that exists here. This is the only step that cannot be automated, and the only one that fails at runtime if it is skipped.

  3. 03
    Point one environment at the new base URL

    Change the two lines and deploy staging. The provider block, models array and streaming loop carry over unedited; remove plugins and transforms until those execution layers are available.

  4. 04
    Reconcile a handful of requests

    Take the X-Routehook-Request-Id from a few responses and read GET /v1/generation?id=req_…. Compare usage against what you were paying and reference_value against the vendor's own list price.

  5. 05
    Swap the live key, then revoke the old one

    In that order. Revocation on either side takes effect immediately, so doing it the other way round takes the integration down for the length of a deploy.