
# Errors reference

Every error the inference API returns uses one envelope:

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "human-readable explanation",
    "code": "machine-readable code",
    "param": "the offending field, when there is one"
  }
}
```

`type` is one of a small fixed set (`authentication_error`,
`invalid_request_error`, `not_found`, `provider_error`,
`provider_unavailable`, `rate_limit_error`, `internal_server_error`).
`code` is more specific — branch on it in code, show `message` to
humans. The messages below are the real strings the gateway produces,
not paraphrases.

Branch on `code`, not on `type`. Several codes share a `type`, because
`type` is the coarse OpenAI-compatible bucket and `code` is what tells
you which failure you actually hit — `provider_unavailable` covers a
region that isn't served, an exhausted daily quota **and** an upstream
that never answered, and those need three different responses from a
client.

Every `code` the inference API can emit appears on this page — those in
a JSON error body, those from the auth and rate-limit middleware that
runs ahead of every route, and the one delivered as an SSE error event
on an already-started stream. That is enforced by a test which walks
the gateway source for every emission site and fails the build if a
code ships without a payload here.

## 401 — authentication

You'll see one of:

```json
{
  "error": {
    "type": "authentication_error",
    "message": "Missing Authorization header",
    "code": "authentication_error"
  }
}
```

```json
{
  "error": {
    "type": "authentication_error",
    "message": "API key not registered. Check the key was copied in full, or create a new one at /dashboard/keys. If it was working before, it may have been revoked.",
    "code": "api_key_not_registered"
  }
}
```

A key whose *shape* rules it out gets a different code, because the fix
is different — and we can tell without looking it up:

```json
{
  "error": {
    "type": "authentication_error",
    "message": "API key is malformed: the key body is 20 characters, expected 43 — this is usually a copy-paste truncation. Re-copy the full token, or create a new key at /dashboard/keys.",
    "code": "api_key_malformed"
  }
}
```

`api_key_malformed` covers a truncated or over-copied key (the body is
not exactly 43 characters), a token that doesn't start with `sk-lr-`,
and the retired `sk-bf-*` scheme:

```json
{
  "error": {
    "type": "authentication_error",
    "message": "API key is malformed: this is a retired sk-bf-* key and the scheme is no longer accepted. Create a new key at /dashboard/keys.",
    "code": "api_key_malformed"
  }
}
```

```json
{
  "error": {
    "type": "authentication_error",
    "message": "API key has expired. Create a new key in the dashboard.",
    "code": "api_key_expired"
  }
}
```

**Fix:** send `Authorization: Bearer sk-lr-...` with a key from
[the dashboard](../getting-started/api-keys). `api_key_malformed` means
the token can't be one of ours as written — nearly always a copy-paste
truncation, so re-copy the whole thing. `api_key_not_registered` means
the shape is right but no account owns it: check you copied the whole
key and are using the right account, or mint a new one; a key that used
to work may have been revoked. `api_key_expired` means the key reached its
expiry date: keys have a bounded lifetime (default 90 days), so create
a fresh key in the dashboard and rotate it in — the expired one stays
listed there as a reminder but can no longer authenticate.

## 402 — insufficient credits

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "Insufficient credits. Balance: $0.0000. Your balance must be above zero to make requests. Please add credits to your account.",
    "code": "insufficient_credits"
  }
}
```

The balance is checked before the request is forwarded, so a 402 never
costs you tokens. Any positive balance admits a request; because usage
is settled after the response, a single request can take the balance
below zero, and further requests 402 until you top up.

**Fix:** top up in [Credits & billing](credits-and-billing).

## 400 — malformed request

Missing or empty `messages`:

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "messages field is required and cannot be empty",
    "code": "invalid_request_error",
    "param": "messages"
  }
}
```

A model id that doesn't match the public shape
(`{provider}/{creator}/{model}[/{locode}]` — see
[available models](../models/available)):

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "model id must be {provider}/{creator}/{model}[/{locode}]: got 5 segments",
    "code": "invalid_request_error",
    "param": "model"
  }
}
```

An alias name that violates the naming rule:

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "alias name must be 1-64 chars: lowercase a-z, 0-9, '-' or '_', starting with a letter or digit (got \"My Alias\")",
    "code": "invalid_alias_name",
    "param": "model"
  }
}
```

**Fix:** the message names the field; `param` confirms it. Compare
against the request shape in
[your first completion](../getting-started/first-completion).

## 413 — request body too large

The request body exceeds the 32 MB cap. This is checked before the body
is read, so an oversized request is refused without being uploaded in
full:

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "Request body is too large",
    "code": "request_too_large"
  }
}
```

**Fix:** the cap is on the whole JSON body, so it is usually a base64
image or a very long transcript that pushes a request over. Send images
by URL instead of inline base64 where the model accepts one, or trim
the conversation history you replay on each turn.

## 404 — the route doesn't exist

Unknown model on a provider:

```json
{
  "error": {
    "type": "not_found",
    "message": "model \"anthropic/claude-opus-4.6\" is not offered by provider \"openai\"",
    "code": "not_found",
    "param": "model"
  }
}
```

A model that is only served in specific regions, addressed with an
explicit `/global` it doesn't have. (Omitting the region entirely does
**not** produce this error — it resolves to the model's default region;
see [how IDs resolve](../models/routing#how-ids-resolve). You only see
this when you pin `global` explicitly.)

```json
{
  "error": {
    "type": "not_found",
    "message": "model \"anthropic/claude-opus-4.6\" on provider \"vertex\" is served only in specific regions and has no global endpoint. Append a locode (e.g. \"vertex/anthropic/claude-opus-4.6/{locode}\") and check /providers/vertex for available regions, or omit the region to route to the model's default one",
    "code": "not_found",
    "param": "model"
  }
}
```

An alias that doesn't exist on your account:

```json
{
  "error": {
    "type": "not_found",
    "message": "alias \"alias/big\" does not exist on this account — create or repoint aliases in the dashboard at /dashboard/aliases",
    "code": "alias_not_found",
    "param": "model"
  }
}
```

A model we've fully disabled is reported as absent rather than as
broken, because for callers it is:

```json
{
  "error": {
    "type": "internal_server_error",
    "message": "Model not found",
    "code": "model_not_found",
    "param": "model"
  }
}
```

(The `type` on that one is a known wart — branch on the `code`, which is
`model_not_found`, and on the `404` status. A *soft*-disabled model is
different: it returns `503 model_unavailable` and names a reason.)

**Fix:** browse the [model catalog](../models/available) or
`GET /v1/models` for exact ids; aliases are per-account, so a key from
another account won't see yours.

## 503 — the route exists but can't serve you right now

A region the model isn't served in. This is deliberate: we never
silently substitute a different region — the region you pin is the
region you get, even when a "nearby" one would work:

```json
{
  "error": {
    "type": "provider_unavailable",
    "message": "model \"mistral/mistral-large\" is not served in region \"de-ber\" on provider \"mistral\" — check /providers/mistral for available regions",
    "code": "provider_unavailable",
    "param": "model"
  }
}
```

A model we've marked unavailable names the reason when there is one:

```json
{
  "error": {
    "type": "provider_unavailable",
    "message": "This model is currently unavailable: deprecated upstream. See https://lowrouter.ai/docs/model-availability",
    "code": "model_unavailable",
    "param": "model"
  }
}
```

The same code covers a model whose every route is currently quota-held,
so nothing callable is left to route to:

```json
{
  "error": {
    "type": "provider_unavailable",
    "message": "The model 'mistral/mistral-large' has no currently available route. See https://lowrouter.ai/docs/model-availability",
    "code": "model_unavailable",
    "param": "model"
  }
}
```

When the route itself fails upstream, the request returns 503 naming
the provider that failed and quoting what it said:

```json
{
  "error": {
    "type": "provider_error",
    "message": "Provider mistral failed: upstream 503 service unavailable",
    "code": "provider_error"
  }
}
```

LowRouter does not silently retry on a different provider — an explicit
ID is a pin and an auto-routed request commits to the winner of its
ranking — so the provider you see is the provider you asked for,
directly or by delegation. `lowrouter_metadata.providers_attempted`
shows what was tried.

`provider_error` also covers a model we can't price. We decline rather
than serve a request we couldn't bill correctly:

```json
{
  "error": {
    "type": "provider_error",
    "message": "This model is temporarily unavailable due to a pricing configuration issue. Please try another model or contact support.",
    "code": "provider_error"
  }
}
```

If that same pricing fault is only discovered *after* a stream has
started, there is no status line left to change — so it arrives as an
SSE error event on the open stream instead, carrying its own code:

```json
{
  "error": {
    "type": "provider_error",
    "message": "This model is temporarily unavailable due to a pricing configuration issue. Please try another model or contact support.",
    "code": "cost_unresolvable"
  }
}
```

That event is sent as a `data:` frame and the stream is then closed
**without** a `data: [DONE]`. The missing terminator is the signal: a
stream that ends without it is incomplete, not finished. This is
request-scoped — the model itself is not blocked.

A provider with no credentials configured on our side fails closed:

```json
{
  "error": {
    "type": "provider_unavailable",
    "message": "Provider ionos is not configured",
    "code": "provider_not_configured"
  }
}
```

And a replica that has just started may not have its routing snapshot
yet. This one is genuinely transient — retry shortly:

```json
{
  "error": {
    "type": "internal_server_error",
    "message": "Auto routing is not ready on this replica yet — retry shortly",
    "code": "service_unavailable",
    "param": "model"
  }
}
```

**Fix:** pick a served region from `/providers/{provider}`, or point
an [alias](../models/aliases) at an alternative route so clients don't
need redeploying when a region is down. `service_unavailable` needs
nothing but a retry; `provider_not_configured` and `provider_error` on
a pricing fault are ours to fix — tell us if one persists.

### Daily quota exhausted

When an upstream provider has spent its daily token quota for a route,
retrying is pointless until the quota resets — so the gateway tells you
exactly that, with `"code": "provider_quota_exhausted"` and a
`Retry-After` header carrying the seconds until the reset (midnight
UTC):

```json
{
  "error": {
    "type": "provider_unavailable",
    "message": "Provider aws-bedrock has exhausted its daily token quota for bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0 in jp-tyo. The quota resets at midnight UTC; retry after the Retry-After interval.",
    "code": "provider_quota_exhausted",
    "param": "model"
  }
}
```

The region in the message is the locode the route is billed and reported
under — the same one that appears as the fourth segment of a model ID —
not the upstream provider's own region name.

While the quota is exhausted the affected region also disappears from
`/v1/models`, so a fresh listing won't hand you an ID that can't serve.
Your pin is still honoured — nothing is substituted.

**Fix:** honour `Retry-After` instead of hammering the route, or point
the request (or an [alias](../models/aliases)) at another region or
provider serving the same model.

## 504 — the provider didn't answer in time

Every dispatch is bounded. An upstream that accepts the connection and
then goes silent used to hang until something else gave up; now the
gateway answers for it, with a `504` and `"code": "provider_timeout"`:

```json
{
  "error": {
    "type": "provider_unavailable",
    "message": "provider ionos did not respond within the first-byte deadline of 30s",
    "code": "provider_timeout"
  }
}
```

This is the one status you should retry. A 503 means the route can't
serve you and retrying it unchanged is usually pointless; a 504 means
we never heard back, so the same request may well succeed on the next
attempt or on another route.

That reasoning holds because a `504` payload means nothing was
delivered and nothing was billed. It does **not** extend to the
truncated-stream case below, where a `200` was already sent and you
were charged for the tokens that arrived — retrying that one re-runs
work you have paid for, so treat it as a resume-or-accept decision
rather than an automatic retry.

`type` stays `provider_unavailable` — an existing client that branches
on `type` keeps working unchanged. What's new is the `code` and the
status.

Three budgets can expire, and the message names which one:

| Budget | Limit | What it catches |
|---|---|---|
| `first-byte` | 30s | An upstream that never sends a first byte — the dark-provider case. Time to first byte doesn't depend on how long a generation runs, so this stays tight without truncating slow-but-working completions. |
| `stream-idle` | 1m30s | A stream that starts and then stalls between chunks. |
| `overall` | 5m0s | The total budget for one attempt. A ceiling against pathology, not a latency target. |

The other two read the same way, with the budget rendered as the
gateway prints it:

```json
{
  "error": {
    "type": "provider_unavailable",
    "message": "provider ionos did not respond within the stream-idle deadline of 1m30s",
    "code": "provider_timeout"
  }
}
```

```json
{
  "error": {
    "type": "provider_unavailable",
    "message": "provider ionos did not respond within the overall deadline of 5m0s",
    "code": "provider_timeout"
  }
}
```

There is **no `Retry-After` header** on a 504 — we don't know when the
upstream will come back, and inventing an interval would be a guess.
Back off with jitter instead.

**One case produces no JSON at all.** If a *streaming* response has
already started — the `200` is sent and chunks are on the wire — a
stall can't be retracted into an error body: the status line is spent,
and appending JSON to a half-finished SSE stream would give you
something that parses as neither. The stream is terminated with
`data: [DONE]` instead. So a stream that ends early without a
`finish_reason` may be a stall rather than a completion; you're billed
only for the tokens actually delivered. A non-streaming request, and a
streaming one that hasn't sent anything yet, both still get the 504
payload above.

**Fix:** retry with exponential backoff and jitter. If one provider
times out repeatedly, route around it — an
[`auto/` id](../models/routing) picks a live route for you, or point an
[alias](../models/aliases) elsewhere so clients don't need redeploying.

## 429 — rate limited

Two limiters can produce a `429`, both with a `Retry-After` header. The
per-IP limiter guards the edge:

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "Rate limit exceeded. Too many requests from your IP address. Try again in 30 seconds",
    "code": "rate_limit_exceeded"
  }
}
```

Your key's own RPM/TPM limits produce the same `code` under a different
`type`, and the message names which budget you hit:

```json
{
  "error": {
    "type": "rate_limit_error",
    "message": "Rate limit exceeded: 60 requests per minute. Try again in 12 seconds.",
    "code": "rate_limit_exceeded"
  }
}
```

The token-per-minute budget reads the same way with `tokens` in place of
`requests`. Because both limiters share `"code": "rate_limit_exceeded"`,
`code` alone won't tell you which one you hit — check `type`, or just
honour `Retry-After`, which is correct for both.

An *upstream provider's* rate limit is not passed through as a 429:
it surfaces as one of the `503` provider failures above.

**Fix:** honour `Retry-After` and back off with jitter — or route the
workload across more than one provider, which is rather the point of
a router.

## Managing aliases

These come from the alias endpoints (`/v1/aliases`), not from
completions. Creating a name that already exists on your account is a
`409`:

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "An alias named \"big\" already exists on this account",
    "code": "alias_exists",
    "param": "name"
  }
}
```

Accounts have a bounded number of alias slots (`422`):

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "Alias limit reached (5). Delete an alias to free a slot, or contact support to raise the limit.",
    "code": "alias_limit_reached",
    "param": "name"
  }
}
```

A target that isn't a routable model — unknown model or provider, a
region that isn't served, a region that's required and missing — is a
`422` carrying the router's own explanation, so the fix is the same one
you'd apply to a completion:

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "Invalid alias target: model \"mistralai/mistral-large\" is not served in region \"de-ber\" on provider \"mistral\" — check /providers/mistral for available regions",
    "code": "invalid_alias_target",
    "param": "target"
  }
}
```

A target sent in a shape we don't accept gets the shape rules back:

```json
{
  "error": {
    "type": "invalid_request_error",
    "message": "Target requires provider and canonical_id (locode optional, default global). Send them as flat top-level fields: {\"provider\": \"...\", \"canonical_id\": \"...\", \"locode\": \"...\"} — a nested {\"target\": {...}} object or a \"provider/canonical_id/locode\" string is also accepted. On repoint, send all fields, not just the one changing.",
    "code": "invalid_alias_target",
    "param": "target"
  }
}
```

Note the last sentence: a repoint takes the **full** triple. Sending
only the field you're changing is the common mistake — absent fields
decode as empty, not as "leave alone".

**Fix:** `param` names the field. See [aliases](../models/aliases) for
the full lifecycle.

## 500 — something broke on our side

You should not see these. When you do, it's ours to fix — the message
says which step failed, and nothing was billed:

```json
{
  "error": {
    "type": "internal_server_error",
    "message": "Failed to create alias",
    "code": "internal_server_error"
  }
}
```

```json
{
  "error": {
    "type": "internal_server_error",
    "message": "Failed to retrieve generation records",
    "code": "api_error"
  }
}
```

**Fix:** retry once in case it was transient, then
[tell us](../about) — the model id, the timestamp and the exact message
are what let us find your request in the logs.

## What you'll never get

No silent substitutions: a failed constraint (region, jurisdiction,
alias) is an error, not a quiet reroute to somewhere you didn't ask
for. If you got a 2xx, the response's `lowrouter_metadata` tells you
exactly who served it and where — see
[per-request metadata](../models/per-request-metadata).
