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.
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/completionsand/v1/completions, includingmodels,stream,tools,response_format, provider routing and every sampling parameter. - The streaming framing:
data: {…}frames, terminated bydata: [DONE]. usage, withprompt_tokens,completion_tokensandtotal_tokens.- The
Authorization: Bearerheader, 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/transcriptionsand/v1/rerank.
Route map
| OPENROUTER | HERE | WHAT CHANGES |
|---|---|---|
| POST /api/v1/chat/completions | POST /v1/chat/completions | Nothing. Same body, same response, same SSE framing. |
| POST /api/v1/completions | POST /v1/completions | Nothing on the wire. The prompt is wrapped as one user message upstream and unwrapped back to choices[].text. |
| GET /api/v1/models | GET /v1/models | Same shape. The ids are this catalogue's slugs. Check them, do not assume them. |
| GET /api/v1/key | GET /v1/key | Their seven fields, plus key_id, account_id and timestamps. |
| GET /api/v1/credits | GET /v1/credits | total_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/user | GET /v1/models/user | Answers, but returns the full catalogue: there are no per-key model allowances here to narrow it by. |
| GET /api/v1/models/count | GET /v1/models/count | Same shape. |
| GET /api/v1/model/:author/:slug | GET /v1/model/{author}/{slug} | Same shape. Singular `model`: their spelling for this route, kept. |
| GET /api/v1/embeddings/models | GET /v1/embeddings/models | Same shape, with total_count. Likewise /v1/images/models and /v1/videos/models. |
| POST /api/v1/responses | POST /v1/responses | Same body and same output[] / output_text response. Served by every text model here, not one vendor's. |
| POST /api/v1/embeddings | POST /v1/embeddings | Nothing. Same body, same list response. |
| POST /api/v1/images | POST /v1/images | Same body. /v1/images/generations is the same handler under OpenAI's path. |
| POST /api/v1/videos | POST /v1/videos | Same body. Poll with GET /v1/videos/{job_id} and fetch bytes from /content. |
| POST /api/v1/audio/speech | POST /v1/audio/speech | Same body. Answers with the audio bytes and the vendor's content type. |
| POST /api/v1/audio/transcriptions | POST /v1/audio/transcriptions | The JSON form, with the audio base64 under input_audio. |
| POST /api/v1/rerank | POST /v1/rerank | Same 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
| OPENROUTER | HERE |
|---|---|
| model:nitro | The model id on its own. There is no per-request sort. Routing is ours. |
| model:floor | The model id on its own. There is no per-request sort. Routing is ours. |
| model:free | Nothing. 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.
{
"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
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
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
/keysAPI and no OAuth exchange. Keys are created in the dashboard, andis_provisioning_keyonGET /v1/keyis alwaysfalse. - 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_tieris alwaysfalse, and a:freeid 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/embeddingsandPOST /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_valueon every generation record. What the same call costs at the vendor's list price, measured per request rather than asserted per month.
Cutting over
- 01Issue 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.
- 02Resolve every model id
Read
GET /v1/modelsand 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. - 03Point one environment at the new base URL
Change the two lines and deploy staging. The
providerblock,modelsarray and streaming loop carry over unedited; remove plugins and transforms until those execution layers are available. - 04Reconcile a handful of requests
Take the
X-Routehook-Request-Idfrom a few responses and readGET /v1/generation?id=req_…. Compareusageagainst what you were paying andreference_valueagainst the vendor's own list price. - 05Swap 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.