Errors reference
Every error the inference API returns uses one envelope:
{
"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:
{
"error": {
"type": "authentication_error",
"message": "Missing Authorization header",
"code": "authentication_error"
}
}{
"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:
{
"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:
{
"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"
}
}{
"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. 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
{
"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.
400 — malformed request
Missing or empty messages:
{
"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):
{
"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:
{
"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.
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:
{
"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:
{
"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. You only see
this when you pin global explicitly.)
{
"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:
{
"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:
{
"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 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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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 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):
{
"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) 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":
{
"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:
{
"error": {
"type": "provider_unavailable",
"message": "provider ionos did not respond within the stream-idle deadline of 1m30s",
"code": "provider_timeout"
}
}{
"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 picks a live route for you, or point an
alias 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:
{
"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:
{
"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:
{
"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):
{
"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:
{
"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:
{
"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 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:
{
"error": {
"type": "internal_server_error",
"message": "Failed to create alias",
"code": "internal_server_error"
}
}{
"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 — 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.
