# Errors

Every failure carries one canonical error class, a short stable string such as `rate_limit` or `insufficient_quota`. The class is in the body and in the `x-tm-error-code` response header. Switch on the class in your code. Do not parse the message.

The body keeps the native shape of the surface you called (OpenAI or Anthropic), so your SDK's error handling keeps working.

A failure after RouterPlus accepted your request also has an origin, in the `x-tm-error-origin` header and in `origin` in the body's metadata:

| Origin | Meaning |
|---|---|
| `gateway_admission` | A limit or balance of yours |
| `upstream_quota` | A provider's declared capacity |
| `upstream` | The provider answered |
| `gateway_infrastructure` | RouterPlus's side |
| `authorization` | Your key or connection |

When a limit refused the request, `x-tm-limit-scope`, `x-tm-limit-kind` and, where known, `x-tm-limit-id` name it. See [Limits and capacity](/docs/admission).

## Where the class lives

OpenAI surface (`POST /v1/chat/completions`):

```json
{
  "error": {
    "code": "model_unavailable",
    "message": "model \"gpt-5-nano\" is not in the catalog; GET /v1/models lists what this key can serve",
    "type": "invalid_request_error",
    "metadata": { "error_type": "model_unavailable" }
  },
  "request_id": "6f8f57b2-..."
}
```

Anthropic surface (`POST /v1/messages`):

```json
{
  "type": "error",
  "error": {
    "type": "not_found_error",
    "message": "model \"gpt-5-nano\" is not in the catalog; GET /v1/models lists what this key can serve",
    "error_type": "model_unavailable"
  },
  "request_id": "6f8f57b2-..."
}
```

- The canonical class is always in `error.metadata.error_type` (OpenAI surface) or `error.error_type` (Anthropic surface).
- RouterPlus generates every error body and never relays a provider's raw error body. When a provider answered, its HTTP status is in `x-tm-upstream-status`.
- On the Anthropic surface, `error.type` is the native Anthropic type string (`rate_limit_error`, `billing_error`, ...), so the Anthropic SDK's error classification and retries work unmodified.
- On the OpenAI surface, `error.code` carries the canonical class, and `error.type` is a generic string. Switch on `metadata.error_type`, `error.code` or the HTTP status.
- Three OpenAI-surface errors match OpenAI's own wire byte for byte, so OpenAI SDKs and coding agents recognize them:
  - Out of credits: `code` and `type` are both `insufficient_quota`.
  - A prompt too long for the model: `code` is `context_length_exceeded`. See [Context overflow](#context-overflow).
  - A rate limit on `/v1/chat/completions` and `/v1/responses`: `code` is `rate_limit_exceeded`, and `type` is `requests`, or `tokens` for a token limit.
- An error before routing (bad key, unknown route, a crash in a RouterPlus handler) takes its shape from the path: Anthropic on `/v1/messages`, OpenAI on every other path.
- On `POST /v1/responses`, a model served through the chat translation (Claude and the other models OpenAI does not serve) gets OpenAI's own codes where clients act on them: `rate_limit_exceeded`, `context_length_exceeded`, `model_not_found` and `invalid_prompt` in `error.code`. The canonical class stays in `error.metadata.error_type`. See [POST /v1/responses](/docs/api-responses#errors).

### The provider's own words

When a provider refused the request, the message carries the provider's own words: `upstream <route> returned <status>: <words>`. Example: `upstream anthropic returned 400: messages.1.output_config: Extra inputs are not permitted`. Coding agents read these words to drop the refused feature and retry.

- The words are the `message` of the provider's JSON error, on one line, at most 1,000 characters.
- A marker replaces links, host names, keys, and account, organization and project ids.
- The message is `upstream <route> returned <status>` with no words when:
  - the provider's error is not JSON,
  - the words would repeat RouterPlus's key,
  - on a RouterPlus route, the error is about RouterPlus's provider account (a 401, 402, 403 or 429, credit, billing or quota).
- On your own connection (BYOK), you also get the words about your account.
- A [closed dedicated endpoint](/docs/dedicated-endpoints#closed-endpoints) never shows a provider's words. Dedicated capacity is named `routerplus`.

### Limit metadata

When a limit or a balance refused the request, `error.metadata` on both surfaces also has `origin`, `limit_scope`, `limit_kind`, `limit_id`, `retryable`, and `retry_at` or `reset_at` when known. On the OpenAI surface they sit beside `error_type`.

> [!NOTE]
> A 502 or a 504 does not reach you as the JSON body on this page. Cloudflare carries all
> traffic to the gateway and replaces these two statuses with its own short error page (plain
> text such as `error code: 502`, or an HTML page), without `x-request-id`. Treat both as
> retryable. When the request reached a provider, `GET /v1/usage` lists it with its request id.

## The canonical classes

| `error_type` | HTTP | OpenAI `error.code` | Anthropic `error.type` | Meaning |
|---|---|---|---|---|
| `auth` | 401 (or a relayed 401/402/403) | `auth` | `authentication_error` | Your key is missing, wrong or disabled. Rarely, RouterPlus's own provider account failed on every candidate (see [Failover](#failover-and-what-the-error-you-see-means)). |
| `invalid_request` | 400 | `invalid_request` | `invalid_request_error` | The request cannot be served as sent. See [invalid_request](#invalidrequest). |
| `model_not_priced` | 400 | `model_not_priced` | `invalid_request_error` | The model is listed but has no price row. RouterPlus refuses the request and does not guess $0. |
| `context_overflow` | 400 (on `/v1/decisions`, the provider's status: 413 from Cloudflare on Clef and Clef-flash) | `context_length_exceeded` (`context_overflow` on `/v1/decisions`) | `invalid_request_error` | The prompt exceeds the model's context window. See [Context overflow](#context-overflow). |
| `content_policy` | provider's status (usually 400) | `content_policy` | `invalid_request_error` | The provider refused the content. An OpenAI image moderation refusal (`moderation_blocked`) is relayed at the provider's own status (usually 400). Never billed, never rerouted, never a health strike. |
| `model_unavailable` | 404 / 502 / 503 | `model_unavailable` | `not_found_error` | The model cannot be served. See [model_unavailable](#modelunavailable). |
| `upstream_error` (timeout) | 504 | `upstream_error` | `api_error` | The provider exceeded RouterPlus's wait. See [Timeouts](#timeouts). Retryable. A single timeout never trips the health circuit. |
| `not_found` | 404 | `not_found` | `not_found_error` | No such route. On `/v1/generation`, an id that is not a request id. |
| `request_too_large` | 413 | `request_too_large` | `request_too_large` | Body over 10 MB (1 MB on `POST /v1/decisions`). |
| `rate_limit` | 429 | `rate_limit_exceeded` (`rate_limit` on `/v1/decisions`, images and video) | `rate_limit_error` | A rate limit refused the request. See [rate_limit](#ratelimit). |
| `email_not_verified` | 403 | `email_not_verified` | `permission_error` | The key's account has not opened its email verify link. RouterPlus refuses every request until it does, even a free one. |
| `insufficient_quota` | 429 | `insufficient_quota` | `billing_error` | Balance too low, or a monthly spend cap hit. No `retry-after`. A spend cap also carries `x-should-retry: false`. |
| `usage_limited` | 429 | `usage_limited` | `rate_limit_error` | RouterPlus detected unusual usage on an organization that has only trial credit, and limited this request. `retry-after` says when to try again. |
| `usage_restricted` | 403 | `usage_restricted` | `permission_error` | RouterPlus detected unusual usage on an organization that has only trial credit. It refuses this kind of request until support reviews the account. |
| `upstream_error` | 5xx, or provider's 4xx | `upstream_error` | `api_error` | The provider failed and no alternate could serve. At 4xx, the provider rejected the request itself. On `/v1/decisions`, OpenAI's 400 on GPT-6 Luna Decisions names your question id and System One's field, for example `questions.team.criteria`. On the images route, a 200 that is unparseable or has no image is a 502 that bills nothing. |
| `gateway_error` | 400 / 500 / 502 / 503 / 504 | `gateway_error` | `api_error` | RouterPlus refused or could not route the request. See [gateway_error](#gatewayerror). |

When the status comes from a provider, `x-tm-upstream-status` also has the provider's own HTTP status.

On `POST /v1/decisions`, GPT-6 Luna Decisions can decline one question and answer the rest.
That is not an error: the response is a 200, the declined question's answer is
`{"type": "refusal"}`, and the call is billed. Check each answer's `type`.

### invalid_request

The message names the field. Causes:

- The body is not valid JSON, or `n>1`.
- No deployment can serve the request as sent:
  - `temperature > 1`.
  - `response_format` or `thinking` across a dialect boundary.
  - Content a translation cannot carry across a dialect boundary: audio, a file id, a file other than a PDF, or an image in a format other than JPEG, PNG, GIF or WebP.
  - A content part the model does not take: an image, file, audio or video part that no route of the model declares.
- A request-level credential or destination (`api_key`, `base_url`, `connection_id`, …), or an unknown `provider` control.
- On `/v1/responses`: `previous_response_id` or `background: true`. RouterPlus keeps no response state.
- On `/v1/images/generations`: a value outside the model's accepted values, `n` outside 1 to 4, `stream`/`partial_images`, `response_format: url`, or a model on the wrong route. An image model on a chat route is also this class, and the message names the right route.
- On `/v1/decisions`:
  - A question in another decision model's format: `schema` on Jev, the Bespoke Nimble models, Clef, Clef-flash, Decider 2B, Kev 4B, Perplexity Decider v1 27B, GPT-6 Luna Decisions, Sage or ScaleDown Classify-1, or `questions` on GLiNER-2.5-Decide.
  - A question kind that does not fit the state.
  - A field the model does not take.
  - Two GLiNER results with one name.
  - On Perplexity Decider v1 27B, an object with `"type": "image_url"` in the state or a question. Decision models take text and JSON.

### model_unavailable

- **404:** the id is not in the catalog (the message echoes your id), or your key's connection does not serve it. On `/v1/decisions`, also when OpenAI no longer gives us access to GPT-6 Luna Decisions (its 404 `model_not_found`, relayed with OpenAI's words).
- **502:** the connection to the provider failed on every candidate.
- **503, on `/v1/decisions`:** the provider refuses RouterPlus's account for capacity. The message says the model is out of capacity at the provider. Cases:
  - Levanto's monthly allowance for Sage is used up. The message says Sage is out of capacity until the provider's next billing period.
  - Fastino refuses GLiNER-2.5-Decide for credit or billing.
  - Bespoke Labs refuses a Bespoke Nimble model for credit (its 402).
  - Cloudflare refuses Clef or Clef-flash for our token or plan (its 401 or 403), or because our account's free daily allocation is used up (its 429 with code 3036).
  - Also a 401, 402 or 403 from any System One provider on our key: TypeSafe (Jev), RouterPlus (Decider 2B and Kev 4B), Perplexity (Perplexity Decider v1 27B) and OpenAI (GPT-6 Luna Decisions).
  - On a connection with your own key, the provider's 401, 402 or 403 keeps its status.

### Timeouts

The provider exceeded RouterPlus's wait (504 `upstream_error`). RouterPlus waits 20 s for the first response headers on streams, the generation budget (450 s) on non-stream calls, or 60 s for an answer on `/v1/decisions`.

- Cold starts on `/v1/decisions`: a RouterPlus cold start, 15 to 20 s, fits inside it. A GLiNER-2.5-Decide cold start can take longer than 60 s.
- On `/v1/decisions`, a System One provider's own 408 (Cloudflare's timeout on Clef and Clef-flash) is this class too.
- So is Perplexity's own 504 on Perplexity Decider v1 27B. It counts against the circuit, as every 5xx does.

### rate_limit

A rate limit refused the request. The limit is one of:

- the key's RPM,
- one of the organization's shared limits,
- a provider pool's declared capacity, or its cooldown after a provider 429,
- the provider rate-limited every candidate.

`x-tm-limit-scope` and `x-tm-limit-kind` say which. `x-tm-limit-id` says whether a pool limit was the pool's (`pool:…`) or your organization's share of it (`pool-share:…`).

On `/v1/decisions`, a provider's 429 never opens the deployment's health circuit. Pools per model:

| Models | Pool | Provider's own 429 |
|---|---|---|
| Bespoke Nimble models | the Bespoke Nimble models share one pool, which takes 7 requests at once, 5 per organization. Bespoke allows 8 at once for our whole account, and one is kept for our test environment. | Comes back as `rate_limit`. |
| Clef and Clef-flash | One shared pool: 200 requests a minute, 150 per organization; at most 8 at a time, 6 per organization. | Cloudflare's 429 when Workers AI is busy (code 3040) comes back as `rate_limit`. Its code 3036 is the 503 `model_unavailable` above. |
| Decider 2B and Kev 4B | Decider 2B and Kev 4B share one RouterPlus pool (11,400 requests a minute, 59 in flight, 75 % per organization), so your organization's own limits bind first. | RouterPlus's own 429 (about 200 requests a second for the whole endpoint) comes back as `rate_limit` and pauses the pool for both models. |
| Perplexity Decider v1 27B | 540 requests a minute (405 per organization) and 8 at once (6 per organization). Its hold counts the state once per question, so a long state with many questions can pass a token limit by itself (`x-tm-limit-kind: tpm`). Ask fewer questions per call. | Perplexity's own 429 (10 requests a second for our organization) comes back as `rate_limit`. |
| GPT-6 Luna Decisions | GPT-6 Luna Decisions' pool takes 24,000 requests a minute (18,000 per organization) and 64 in flight (48 per organization), so your organization's own limits bind first. | OpenAI's own 429 (30,000 requests a minute for our organization) comes back as `rate_limit`. |

### gateway_error

| Status | Cause |
|---|---|
| 400 | RouterPlus refused an output bound (`x-tm-limit-kind: output_bound`): `max_tokens` outside 1 to the model's own maximum, or not a whole number. The message names the maximum. It is 32,768 only when no maximum is declared. `0` and `-1` are refused too, on both surfaces. So are `max_tokens` and `max_completion_tokens` both present and different. `null` counts as not set, and RouterPlus sets its default. |
| 500 | A RouterPlus bug. |
| 502 | The provider's image response exceeded the 32 MiB cap, or a 2xx that RouterPlus's translator cannot represent. Nothing billed. |
| 503 | No capacity now: every deployment serving the model is cooling down (`x-tm-limit-kind: health`, `retry-after: 5`), the platform or this worker is at capacity, or the shared limit store or the ledger journal is unavailable. Nothing was sent to a provider or charged. |
| 504 | The request's routing deadline (450 s, or the policy's `timeout_ms`) passed before RouterPlus could try a provider. |

## Context overflow

A prompt longer than the model's context window is a 400 in the standard shape of the surface you called. Coding agents (Claude Code, Codex, Cursor, Cline and others) look for these words, then compact the conversation and retry. M is the model's context window in tokens, and N is the size of your prompt. The status is 400 whatever the provider sent. `x-tm-upstream-status` keeps the provider's status.

OpenAI surface (`POST /v1/chat/completions`):

```json
{
  "error": {
    "code": "context_length_exceeded",
    "param": "messages",
    "message": "This model's maximum context length is M tokens. However, your messages resulted in N tokens. Please reduce the length of the messages.",
    "type": "invalid_request_error",
    "metadata": { "error_type": "context_overflow" }
  },
  "request_id": "..."
}
```

Anthropic surface (`POST /v1/messages`):

```json
{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "prompt is too long: N tokens > M maximum",
    "error_type": "context_overflow"
  },
  "request_id": "..."
}
```

When the input fits but the input and the output limit together do not, the message says so:

- OpenAI surface: `… However, you requested T tokens (N in the messages, K in the completion). Please reduce the length of the messages or completion.`
- Anthropic surface: ``input length and `max_tokens` exceed context limit: N + K > M, decrease input length or `max_tokens` and try again``

The numbers come only from the provider's own words. When the provider gave none, the message is `This model's maximum context length was exceeded. Please reduce the length of the messages.` or `prompt is too long for the model's context window`. `POST /v1/responses` uses the same `code`, `context_length_exceeded`.

On `/v1/decisions`, the message carries the provider's words:

- Cloudflare refuses a Clef or Clef-flash request that it estimates above 65,536 tokens (its 413, code 5021).
- Perplexity refuses a Perplexity Decider v1 27B question whose prompt (the state and that question) is above 262,144 tokens (its 400).

The class stays `context_overflow` in `error_type` and in `x-tm-error-code`. RouterPlus does not send the request to another provider and bills nothing. `context_length` in [`/api/models.json`](https://demo.routerplus.com/api/models.json) gives each model's window.

## What to do, per class

Coding agents can follow this table as written. "Never blind-retry" means: change the request before you send it again.

| Class | Retry? | Remediation |
|---|---|---|
| `auth` (401 from RouterPlus) | no | Send the key as `Authorization: Bearer tm_vk_...` or `x-api-key: tm_vk_...`. Both work on every endpoint. Complete browser signup and create a key in the console at [https://demo.routerplus.com/console/keys](https://demo.routerplus.com/console/keys). |
| `auth` (relayed 401/402/403) | no | Nothing to fix on your side. This is RouterPlus's provider account. See the note under [Failover](#failover-and-what-the-error-you-see-means). |
| `invalid_request` | never blind-retry | Fix what the message names, then resend. |
| `model_not_priced` | no | Report it with the `x-request-id`. A listed model without a price is a RouterPlus data bug. |
| `context_overflow` | never blind-retry | Shorten the input (a coding agent compacts its conversation) or pick a model with a larger context. RouterPlus does not fail it over. |
| `content_policy` | never blind-retry | Change the prompt. RouterPlus never reroutes it to another provider. |
| `model_unavailable` (404) | no | Pick an id from `GET /v1/models` (or the public list at `https://demo.routerplus.com/api/models.json`). |
| `model_unavailable` (502) | yes | One retry after about 2 s. Every candidate's connection failed. |
| `model_unavailable` (503, decisions) | not soon | See [Decision capacity](#decision-capacity). `metadata.retryable` is `false`. |
| `not_found` | no | Endpoints: `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/images/generations` (alias `/images/generations`), `/v1/decisions`, `/v1/models`, `/v1/usage`, `/v1/generation`, `/v1/limits`, `/v1/route`. |
| `request_too_large` | no | Trim the body under 10 MB (1 MB for a decisions request). The images route also caps the provider response at 32 MiB, but that is a 502 `gateway_error`. |
| `rate_limit` | yes | Wait `retry-after` (when the limit has room again), then retry. A capped wait can need a second try. Coding agents do this themselves ([how each one does](/docs/install#when-you-hit-a-rate-limit)). See [Rate limits & spend caps](/docs/limits) and [Limits and capacity](/docs/admission). |
| `email_not_verified` | **no** | Do not loop. Open the verify link in the signup email (an agent asks its human to). The key works within seconds of the click. |
| `usage_limited` | after `retry-after` | Honor `retry-after` once. Do not loop. If it happens again, contact support. |
| `usage_restricted` | **no** | Contact support to have the account reviewed. |
| `insufficient_quota` | **no** | Do not loop: a human must add money. Add credits, or raise the cap at [https://demo.routerplus.com/console/billing](https://demo.routerplus.com/console/billing). On a cap breach, the exact reset time is in the message and in `x-tm-cap-reset`. |
| `upstream_error` (5xx) | yes | One retry after about 2 s, then report with the `x-request-id`. RouterPlus already failed over if it could. |
| `upstream_error` (4xx) | never blind-retry | The provider rejected the request itself. The message names the provider and its status, and `x-tm-upstream-status` carries the status. |
| `gateway_error` (400) | never blind-retry | Send one output limit, a whole number from 1 to the maximum the message names (32,768 only when no maximum is declared), or send none. |
| `gateway_error` (500) | no | Report it with the `x-request-id`. |
| `gateway_error` (502, images) | yes, once | Nothing was billed. Lower `n` or the quality. |
| `gateway_error` (503) | yes | Wait `retry-after` (or about 2 s) and resend. The request never reached a provider, so a retry cannot double-bill. |
| `gateway_error` (504) | yes | Resend after about 2 s. The deadline passed while RouterPlus was trying candidates. Nothing was billed. |

### Decision capacity

A 503 `model_unavailable` on `/v1/decisions` will not clear soon. The provider's capacity comes back with its allowance or credit:

- Sage: at Levanto's next billing period.
- Clef and Clef-flash after Cloudflare's code 3036: at 00:00 UTC.

Meanwhile, use another decision model, in its own question format.

While the provider refuses, most requests to that model get `gateway_error` (503, "all deployments cooling down") instead, because each refusal counts against the provider's circuit. Treat that the same way on Sage, GLiNER-2.5-Decide, the Bespoke Nimble models, Clef, Clef-flash, Decider 2B, Kev 4B, Perplexity Decider v1 27B and GPT-6 Luna Decisions. Cloudflare's code 3036 is a 429, and a 429 never counts.

## Failure headers

| Header | When | Meaning |
|---|---|---|
| `x-request-id` | always | Quote it in any report. Also the id for `GET /v1/generation?id=`. |
| `x-tm-error-code` | every failure | The canonical class. Not CORS-exposed: browser code reads the class from the body. |
| `x-tm-error-origin` | limit, provider and infrastructure failures | One of the origins at the top of this page. A request refused for its own shape (bad JSON, `n>1`, an unknown model) carries none. |
| `x-tm-limit-scope`, `x-tm-limit-kind`, `x-tm-limit-id` | a refused limit | The scope (`platform`, `org`, `workspace`, `principal`, `key`, `model`, `pool`, `endpoint`), the kind (`rpm`, `rps`, `rps_queue`, `tpm`, `concurrency`, `spend`, `health`, …), and the limit's id when one exists. |
| `x-tm-attempts` | after ≥1 dispatch | Physical provider attempts, failovers included. |
| `x-tm-upstream-status` | when a provider answered | The provider's own HTTP status. |
| `retry-after` | 429 / 503 | Whole seconds to wait. See [retry-after values](#retry-after-values). |
| `x-should-retry` | spend-cap 429 only | `false`. The cap frees only at `x-tm-cap-reset`. OpenAI's and Anthropic's SDKs and Claude Code obey it. A low balance does not carry it, because a balance held by your own requests in flight frees when they end. |
| `x-tm-cap-reset` | spend-cap 429 only | The exact ISO instant the cap resets (the first instant of the next UTC month). |

### retry-after values

`retry-after` is at most 59 seconds, because SDKs and coding agents ignore or stop on a longer wait.

| Case | Value |
|---|---|
| A rate limit | When its one-minute window has room again |
| A full limit on requests at once | 10 |
| A provider's own value | Honored, capped at 59 |
| A provider's 429 without a value | 10 |
| The all-cooling-down 503 | 5 |
| The ledger-journal 503 | 2 |
| `usage_limited` | 60 or 3,600 |
| `insufficient_quota` | none |

## Deliberate refusals

Five refusals that people often hit are deliberate. Each one stops RouterPlus from billing you for a wrong answer.

**`n>1` is a typed 400.** The normalizer returns one choice, so RouterPlus does not accept `n: 4` and bill a garbled single-choice response.

```bash
curl -s https://demo-api.routerplus.com/v1/chat/completions \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"claude-haiku-4-5","n":4,"messages":[{"role":"user","content":"hi"}]}'
```

```json
{
  "error": {
    "code": "invalid_request",
    "message": "n>1 is not supported in v1; request a single choice",
    "type": "invalid_request_error",
    "metadata": { "error_type": "invalid_request" }
  },
  "request_id": "..."
}
```

**Content a model does not take is a typed 400.** A provider that does not read images can drop the image, answer the rest and still bill. So RouterPlus sends an image, file, audio or video part only to a route whose listing takes that kind. It refuses a part that no route of the model takes, before it reserves or sends anything. `GET /v1/models` lists what each model takes in `architecture.input_modalities`.

```bash
curl -s https://demo-api.routerplus.com/v1/chat/completions \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"gpt-4.1-mini","messages":[{"role":"user","content":[{"type":"text","text":"What is said in this recording?"},{"type":"input_audio","input_audio":{"data":"UklGRiQAAABXQVZF","format":"wav"}}]}]}'
```

```json
{
  "error": {
    "code": "invalid_request",
    "message": "messages[0].content[1]: model \"gpt-4.1-mini\" does not take audio input; it takes text, image and file",
    "type": "invalid_request_error",
    "metadata": { "error_type": "invalid_request" }
  },
  "request_id": "..."
}
```

**No model substitution.** An unknown model id is a 404 that echoes the string you sent. RouterPlus never swaps in a similar model. The 404 is the same for an id that never existed and one that is not served, so it does not reveal which ids exist.

**A content-policy refusal never reroutes.** RouterPlus never sends a refused request to a more permissive provider. If you get `content_policy`, change the prompt.

**A failed image generation bills nothing.** A failed, over-cap or malformed generation returns a 502. `n` above 4 on the images route is a typed 400, like `n > 1` on chat. See [POST /v1/images/generations](/docs/api-images).

## Failover, and what the error you see means

Before any output reaches you, RouterPlus fails over between deployments with zero backoff, and you see no error.

| Fails over | Never fails over |
|---|---|
| `rate_limit` | `content_policy` |
| `auth` (upstream) | `context_overflow` (reshape the request) |
| `model_unavailable` | provider 4xx rejections (the request is the problem) |
| 5xx `upstream_error` | |

If a class from the first column reaches you, RouterPlus tried every healthy candidate. `x-tm-attempts` says how many.

> [!NOTE]
> A relayed 402 (class `auth`) is never your fault. An upstream 401, 402 or 403 means RouterPlus's account with that provider has a credential or payment problem. RouterPlus treats it as a health signal: the error is failover-eligible and counts against that provider's uptime, so traffic moves away. You see it only when every deployment serving the model has the same problem. On `/v1/decisions`, most of these refusals come back as 503 `model_unavailable` instead, with `metadata.retryable: false` (see [model_unavailable](#modelunavailable)).

## Mid-stream failures (HTTP 200 already committed)

After your answer starts streaming, RouterPlus never switches providers. If the provider fails mid-answer, you get one terminal error event inside the 200 stream, and nothing after it. The OpenAI surface sends a chunk with `finish_reason: "error"` and `metadata.error_type`. The Anthropic surface sends a native `error` event with `error_type`. You decide whether to retry. [Streaming](/docs/streaming#when-a-provider-dies-mid-stream) shows the frames and where to read the usage.

## Reading the class in code

```python
import os
from openai import OpenAI, APIStatusError

client = OpenAI(base_url="https://demo-api.routerplus.com/v1", api_key=os.environ["TM_API_KEY"])

try:
    out = client.chat.completions.create(
        model="claude-haiku-4-5",
        messages=[{"role": "user", "content": "hello"}],
    )
except APIStatusError as e:
    err = e.response.json().get("error") or {}
    kind = (err.get("metadata") or {}).get("error_type") \
        or e.response.headers.get("x-tm-error-code")
    origin = e.response.headers.get("x-tm-error-origin")
    request_id = e.response.headers.get("x-request-id")
    # kind is one of the canonical classes in the table above
```
