# Migrate from OpenRouter

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

Section: Guides. Canonical page: https://routehook.ai/docs/migrate-openrouter

`/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.

**The whole change, for most integrations**

```diff
 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

| 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. 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.

**A request with its own fallback list**

```json
{
  "model": "openai/gpt-5.4-mini",
  "models": ["anthropic/claude-fable-5"],
  "messages": [{ "role": "user", "content": "Hello" }]
}
```

> **Note: The provider block ports directly**
>
> Chat requests accept OpenRouter's `provider` object: `only`, `ignore`, `order`, `sort`, `allow_fallbacks`, `require_parameters` and `max_price`. The block narrows or orders the configured route before credit is reserved and before an upstream is called.

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.

> **Warning: If the block was a data-residency control, talk to us first**
>
> `only` is the one use of it that has no automatic equivalent. A list pinned because a contract says where inference may run is a real requirement, and it is not something failover can honour on your behalf. Get in touch before you migrate rather than after: it is an account-level arrangement here, not a request field.

> **Warning: error.code is a string here**
>
> OpenRouter puts the HTTP status in `error.code`. Here `error.code` is a string — `invalid_request`, `invalid_api_key`, `insufficient_credits`, `model_unavailable`, `rate_limited`, `upstream_unavailable`, `not_found`, `gone` or `duplicate_request` — and the status stays on the response, where it always was. The Errors page lists all nine with the status each arrives on. Error handling that compares `error.code` to a number will read every failure as unrecognised and retry none of them.

## Before

**JavaScript**

```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-5.4-mini",
  models: ["anthropic/claude-fable-5"],
  provider: { sort: "throughput" },
  messages: [{ role: "user", content: "Hello" }],
});

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

**Python**

```python
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key=os.environ["OPENROUTER_API_KEY"],
)

completion = client.chat.completions.create(
    model="openai/gpt-5.4-mini",
    messages=[{"role": "user", "content": "Hello"}],
    extra_body={
        "models": ["anthropic/claude-fable-5"],
        "provider": {"sort": "throughput"},
    },
)

print(completion.choices[0].message.content)
```

## After

**JavaScript**

```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-5.4-mini",
  models: ["anthropic/claude-fable-5"],
  messages: [{ role: "user", content: "Hello" }],
});

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

**Python**

```python
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://api.routehook.ai/v1",
    api_key=os.environ["ROUTEHOOK_API_KEY"],
)

completion = client.chat.completions.create(
    model="openai/gpt-5.4-mini",
    messages=[{"role": "user", "content": "Hello"}],
    extra_body={
        "models": ["anthropic/claude-fable-5"],
    },
)

print(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.

## Cutting over

1. **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. **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. **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. **Reconcile a handful of requests**

   Take the `X-Routehook-Request-Id` from a few responses and read `GET /v1/generation?id=req_…`. Compare `usage` and `total_cost` against what you were paying before.

5. **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.

## Next

- [Routing](/docs/routing): Failover, the circuit breaker, and the headers that report them.
- [Cost Tracking](/docs/cost-tracking): Balance, holds, and what a single request cost.
- [Errors](/docs/errors): The eight codes, and which of them are worth retrying.
