# Errors

Every failure carries one canonical error class — a short stable string like `rate_limit` or `insufficient_quota` — in a fixed spot in the body and in the `x-tm-error-code` response header. The body's native shape follows the surface you called (OpenAI-compatible or Anthropic-compatible), so your SDK's built-in error handling keeps working; the canonical class is there so your code never has to parse prose.

A failure that happened after your request was accepted — at a limit, at a provider, or in our infrastructure — also says where it came from. The `x-tm-error-origin` header (and `origin` in the body's metadata) is one of `gateway_admission` (a limit or balance of yours), `upstream_quota` (a provider's declared capacity), `upstream` (the provider answered), `gateway_infrastructure` (our side) or `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-..."
}
```

Six rules govern these bodies:

- `error.metadata.error_type` (OpenAI surface) or `error.error_type` (Anthropic surface) is **always the canonical class**. Every error body is generated by the gateway; a provider's raw error body is never relayed. The provider's HTTP status is in `x-tm-upstream-status` when it answered.
- When a provider refused the request, the message carries the **provider's own words**: `upstream <route> returned <status>: <words>`, for example `upstream anthropic returned 400: messages.1.output_config: Extra inputs are not permitted`. The words are the `message` of the provider's JSON error, on one line and at most 1,000 characters, with links, host names, keys and account, organization and project ids replaced by a marker. Coding agents read these words to drop a feature the provider refused and retry. No words are shown when the provider's error is not JSON, when it would repeat our key, or, on a marketplace route, when it is about our provider account (a 401, 402, 403 or 429, credit, billing or quota): then the message is `upstream <route> returned <status>`. On your own connection (BYOK) you get the words about your account too. A [closed dedicated endpoint](/docs/dedicated-endpoints#closed-endpoints) never shows a provider's words, and dedicated capacity is named `routerplus`.
- On the Anthropic surface, `error.type` additionally maps to the **native Anthropic type string** (`rate_limit_error`, `billing_error`, ...) so the Anthropic SDK's own error classification and retry behavior work unmodified.
- On the OpenAI surface, `error.code` carries the canonical class. Switch on `metadata.error_type`, `error.code`, or the HTTP status — not on `error.type`, which is a generic string. There are three exceptions, all byte-matching OpenAI's own wire so OpenAI SDKs and coding agents recognize them natively: out-of-credits, where both `code` and `type` are `insufficient_quota`; a prompt too long for the model, where `code` is `context_length_exceeded` (see [Context overflow](#context-overflow)); and a rate limit on `/v1/chat/completions` and `/v1/responses`, where `code` is `rate_limit_exceeded` and `type` is `requests`, or `tokens` for a token limit.
- Errors raised before routing (bad key, unknown route, a crash in our handler) pick the shape from the path: requests to `/v1/messages` get Anthropic-shaped bodies, everything else OpenAI-shaped.
- On `POST /v1/responses`, a model served through the chat translation (Claude and the other models that 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`, with the canonical class still in `error.metadata.error_type`. See [POST /v1/responses](/docs/api-responses#errors).

The metadata carries a few more fields after a limit or a balance refused the request: `origin`, `limit_scope`, `limit_kind`, `limit_id`, `retryable`, and `retry_at` or `reset_at` when one is known. On the OpenAI surface they sit beside `error_type` in `error.metadata`; on the Anthropic surface in `error.metadata`.

> [!NOTE]
> A 502 or a 504 does not reach you as the JSON body this page shows. Cloudflare carries all traffic to
> the gateway, and it 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 — or, rarely, the marketplace's own provider account failed on every candidate (see below) |
| `invalid_request` | 400 | `invalid_request` | `invalid_request_error` | body is not valid JSON, `n>1`, or a request no deployment can serve as sent — `temperature > 1`, `response_format` or `thinking` across a dialect boundary, content a translation cannot carry (audio, a file id, a file other than a PDF, an image in a format other than JPEG, PNG, GIF or WebP) across a dialect boundary, or a content part the model does not take (an image, file, audio or video part that no route of the model declares) — the message names the field; 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` (the gateway keeps no response state); on `/v1/images/generations`: a value outside the model's accepted values (the message names the field), `n` outside 1–4, `stream`/`partial_images`, `response_format: url`, a model on the wrong route — and an image model on a chat route (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, `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, or, on Perplexity Decider v1 27B, an object with `"type": "image_url"` in the state or a question, because decision models take text and JSON (the message names the field) |
| `model_not_priced` | 400 | `model_not_priced` | `invalid_request_error` | the model is listed but has no price row; we refuse rather than 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` | prompt exceeds the model's context window; the message uses the standard wording of the surface you called (see [Context overflow](#context-overflow)); on `/v1/decisions`, Cloudflare refuses a Clef or Clef-flash request that it estimates above 65,536 tokens (its 413, code 5021), and Perplexity refuses a Perplexity Decider v1 27B question whose prompt (the state and that question) is above 262,144 tokens (its 400), and there the message carries the provider's words |
| `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` | 404: not in the catalog (message echoes your requested id), or not served by your key's connection, or, on `/v1/decisions`, OpenAI no longer gives us access to GPT-6 Luna Decisions (its 404 `model_not_found`, relayed with OpenAI's words) · 502: connection to the provider failed on every candidate · 503, on `/v1/decisions`: the provider refuses our account for capacity: Levanto's monthly allowance for Sage is used up (the message says Sage is out of capacity until the provider's next billing period), or Fastino refuses GLiNER-2.5-Decide for credit or billing, or Bespoke Labs refuses a Bespoke Nimble model for credit (its 402), or 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); a 401, 402 or 403 from any System One provider on our key reads the same (TypeSafe, on Jev, and RouterPlus, on Decider 2B and Kev 4B, too, and Perplexity, on Perplexity Decider v1 27B, and OpenAI, on GPT-6 Luna Decisions), and on a connection with your own key it keeps its status — the message says the model is out of capacity at the provider |
| `upstream_error` (timeout) | 504 | `upstream_error` | `api_error` | the provider exceeded the gateway's wait: 20 s to first response headers on streams, the generation budget (450 s) on non-stream calls, or 60 s for an answer on `/v1/decisions` (a GLiNER-2.5-Decide cold start can outlast it; a RouterPlus cold start, 15 to 20 s, fits inside it). On `/v1/decisions` a System One provider's own 408 (Cloudflare's timeout on Clef and Clef-flash) is this class too, and so is Perplexity's own 504 on Perplexity Decider v1 27B (it counts against the circuit, as every 5xx does). Retryable; a lone timeout never trips the health circuit |
| `not_found` | 404 | `not_found` | `not_found_error` | no such route — or, 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: the key's RPM, one of the organization's shared limits, a provider pool's declared capacity or its cooldown after a provider 429, or the provider rate-limited every candidate. `x-tm-limit-scope` and `x-tm-limit-kind` say which, and `x-tm-limit-id` 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; the Bespoke Nimble models share one pool, which takes 7 requests at once, 5 per organization, because Bespoke allows 8 at once for our whole account and one is kept for our test environment, and Bespoke's own 429 comes back as this class too; Clef and Clef-flash share one pool (200 requests a minute, 150 per organization; at most 8 at a time, 6 per organization), and Cloudflare's 429 when Workers AI is busy (code 3040) comes back as this class too, while its code 3036 is the 503 `model_unavailable` above; 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, and RouterPlus's own 429 (about 200 requests a second for the whole endpoint) comes back as this class too and pauses the pool for all three; Perplexity Decider v1 27B's pool takes 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 on its own (`x-tm-limit-kind: tpm`: ask fewer questions per call), and Perplexity's own 429 (10 requests a second for our organization) comes back as this class too; 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, and OpenAI's own 429 (30,000 requests a minute for our organization) comes back as this class too |
| `email_not_verified` | 403 | `email_not_verified` | `permission_error` | the key's account signed up but has not opened its email verify link yet; every request is refused until it does, even one that would cost nothing |
| `insufficient_quota` | 429 | `insufficient_quota` | `billing_error` | balance too low, or a monthly spend cap hit; it carries no `retry-after`, and a spend cap also carries `x-should-retry: false` |
| `usage_limited` | 429 | `usage_limited` | `rate_limit_error` | unusual usage was detected on an organization that has only trial credit, and this request was limited; `retry-after` says when to try again |
| `usage_restricted` | 403 | `usage_restricted` | `permission_error` | unusual usage was detected on an organization that has only trial credit, and this kind of request is refused until support reviews the account |
| `upstream_error` | 5xx, or provider's 4xx | `upstream_error` | `api_error` | provider failed and no alternate could serve; at 4xx the request itself was rejected (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 carries no image is a 502 that bills nothing |
| `gateway_error` | 400 / 500 / 502 / 503 / 504 | `gateway_error` | `api_error` | 400: an output bound the gateway refused — `max_tokens` outside 1 to the model's own maximum (the message names it; 32,768 only when no maximum is declared; `0` and `-1` are refused too, on both surfaces) or not a whole number, or `max_tokens` and `max_completion_tokens` both present and different (`x-tm-limit-kind: output_bound`); `null` is not refused: it counts as not set, and the gateway sets its default · 500: our bug · 502: the provider's image response exceeded the 32 MiB cap, or a 2xx our translator cannot represent; nothing billed · 503: no capacity right 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, the shared limit store or the ledger journal is unavailable; nothing was sent to a provider and nothing was charged · 504: the request's routing deadline (450 s, or the policy's `timeout_ms`) passed before a provider could be tried |

When the status comes from a provider, the provider's own HTTP status is also exposed in the `x-tm-upstream-status` header.

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

## Context overflow

A prompt longer than the model's context window is a 400 in the standard shape of the surface you called, because coding agents (Claude Code, Codex, Cursor, Cline and others) look for these exact words before they 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.

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 words say so: `… However, you requested T tokens (N in the messages, K in the completion). Please reduce the length of the messages or completion.` on the OpenAI surface, and ``input length and `max_tokens` exceed context limit: N + K > M, decrease input length or `max_tokens` and try again`` on the Anthropic surface. The numbers come only from the provider's own words; when it 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`.

The canonical class stays `context_overflow` in `error_type` and in the `x-tm-error-code` header. The request is not sent to another provider, and nothing is billed for it. `context_length` in [`/api/models.json`](https://dev.routerplus.com/api/models.json) gives each model's window.

## What to do, per class

| Class | Retry? | Remediation |
|---|---|---|
| `auth` (401 from us) | 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://dev.routerplus.com/console/keys](https://dev.routerplus.com/console/keys) |
| `auth` (relayed 401/402/403) | no | nothing — this is our provider account, not yours; see the note below |
| `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 our data bug |
| `context_overflow` | never blind-retry | shorten the input (a coding agent compacts its conversation when it sees this error) or pick a larger-context model; deliberately **not** failed over |
| `content_policy` | never blind-retry | change the prompt; **never rerouted** to another provider |
| `model_unavailable` (404) | no | pick an id from `GET /v1/models` (or the public list at `https://dev.routerplus.com/api/models.json`) |
| `model_unavailable` (502) | yes | one retry after ~2 s; every candidate's connection failed |
| `model_unavailable` (503, decisions) | not soon | the provider's capacity comes back when its allowance or credit does (for Sage, at Levanto's next billing period; for Clef and Clef-flash after Cloudflare's code 3036, at 00:00 UTC); use another decision model meanwhile, in its own question format. `metadata.retryable` is `false`. 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) |
| `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`, not a 413 |
| `rate_limit` | yes | wait `retry-after` (when the limit has room again), then retry; coding agents do this themselves ([how each one does](/docs/install#when-you-hit-a-rate-limit)) — details in [Rate limits & spend caps](/docs/limits) and [Limits and capacity](/docs/admission) |
| `email_not_verified` | **no** | open the verify link in the signup email; the key works within seconds of the click |
| `usage_limited` | after `retry-after` | honor `retry-after`; if it keeps happening, contact support |
| `usage_restricted` | **no** | contact support to have the account reviewed |
| `insufficient_quota` | **no** | retrying cannot help: add credits, or raise the cap at [https://dev.routerplus.com/console/billing](https://dev.routerplus.com/console/billing); on cap breaches the exact reset time is in the message and `x-tm-cap-reset` |
| `upstream_error` (5xx) | yes | one retry after ~2 s; a failover already happened if one was possible |
| `upstream_error` (4xx) | never blind-retry | the request itself was rejected — the message names the provider and its status; `x-tm-upstream-status` carries it |
| `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 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` and resend — the request never reached a provider, so a retry cannot double-bill |
| `gateway_error` (504) | yes | resend; the deadline passed while candidates were being tried |

## 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 |
| `x-tm-error-origin` | limit, provider and infrastructure failures | `gateway_admission`, `upstream_quota`, `upstream`, `gateway_infrastructure` or `authorization`; 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 | scope (`platform`, `org`, `workspace`, `principal`, `key`, `model`, `pool`, `endpoint`), the kind of limit (`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, at most 59, because SDKs and coding agents ignore or stop on a longer wait: a rate limit says when its one-minute window has room again; a full limit on requests at once sends 10; a provider's own value is honored but capped at 59, and a provider's 429 without one gets 10; the all-cooling-down 503 sends 5; the ledger-journal 503 sends 2; `usage_limited` sends 60 or 3,600; `insufficient_quota` sends none |
| `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 and do not retry. A low balance does not carry it: a balance held by your own requests in flight frees when they end |
| `x-tm-cap-reset` | spend-cap 429 only | exact ISO instant the cap resets (first instant of the next UTC month) |

`x-tm-error-code` is not CORS-exposed; browser code reads the class from the body.

## Strict by design

Five refusals people trip on are deliberate — the alternative in each case is billing you for something wrong.

**`n>1` is a typed 400, not a degraded answer.** The normalizer emits exactly one choice; accepting `n: 4` and silently billing a garbled single-choice response would be dishonest.

```bash
curl -s https://dev-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, not an answer that ignored it.** A provider that does not read images can drop the image, answer the rest, and still bill the request. So an image, file, audio or video part goes only to a route whose listing takes that kind, and a part that no route of the model takes is refused before anything is reserved or sent. `GET /v1/models` lists what each model takes in `architecture.input_modalities`.

```bash
curl -s https://dev-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 silent model substitution.** An unknown model id is a 404 whose message echoes exactly the string you sent — we never quietly swap in a "close enough" model. The 404 is identical whether the id never existed or simply is not served, so the error is not an existence oracle.

**A content-policy refusal never reroutes.** A refusal is not shopped around to a more permissive provider — that is a hard routing rule with no exceptions. If you get `content_policy`, the fix is the prompt.

**Images are all-or-nothing.** A failed, over-cap or malformed generation is a 502 and bills nothing; `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 has reached you, the gateway fails over between deployments invisibly, at zero backoff. Classes that are failover-eligible: `rate_limit`, `auth` (upstream), `model_unavailable`, and 5xx `upstream_error`. Classes that never fail over: `content_policy` (never, as above), `context_overflow` (its own typed class — reshape the request instead), and provider 4xx rejections (the request is the problem). So if a failover-eligible class reaches you, every healthy candidate was tried — `x-tm-attempts` says how many.

> [!NOTE]
> A relayed 402 (class `auth`) is never your fault. An upstream 401/402/403 means the **marketplace's** account with that provider has a credential or payment problem. Payability is treated as a health signal: the error is failover-eligible and counts against that provider's uptime, so an unpayable provider drains traffic automatically. You would only ever see it 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 the table above).

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

Once your answer has started streaming, the gateway never switches providers — a provider death mid-answer becomes exactly one terminal error event **inside** the 200 stream, and nothing follows it (no `data: [DONE]`, no keep-alives, no more chunks).

OpenAI surface — a real chunk envelope with `finish_reason: "error"` and a top-level `error`, so SDKs raise and naive parsers still terminate cleanly:

```json
{
  "id": "chatcmpl-...",
  "object": "chat.completion.chunk",
  "created": 1757000000,
  "model": "claude-haiku-4-5",
  "choices": [{ "index": 0, "delta": { "content": "" }, "finish_reason": "error" }],
  "error": {
    "code": "upstream_error",
    "message": "upstream failure",
    "type": "gateway_error",
    "metadata": { "error_type": "upstream_error" }
  }
}
```

Anthropic surface — a native `error` event, with the canonical class riding alongside the native type:

```json
{ "type": "error", "error": { "type": "api_error", "message": "upstream failure", "error_type": "upstream_error" } }
```

On the OpenAI surface, any known usage and cost are emitted before the terminal error, so the wire stays billable-recomputable. The Anthropic wire has no legal usage slot outside `message_delta`; for an Anthropic-surface stream that died mid-answer, recompute from `GET /v1/generation?id=<x-request-id>`.

Retrying after a mid-stream error is your call — the answer never restarts on a different provider by itself.

## Reading the class in code

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

client = OpenAI(base_url="https://dev-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
```

## Retry discipline for agents

- `rate_limit` → wait `retry-after`, then retry; a capped wait can take a second try.
- `email_not_verified` → do **not** loop; ask the human to open the verify link in their email.
- `insufficient_quota` → do **not** loop; surface to a human (money). See [Rate limits & spend caps](/docs/limits).
- `usage_limited` → do **not** loop; honor `retry-after` once, then tell the human to contact support.
- `usage_restricted` → do **not** retry; tell the human to contact support.
- `upstream_error` at 5xx → one retry after ~2 s, then surface with the `x-request-id`. At 4xx the request itself is the problem — never blind-retry.
- `upstream_error` or `gateway_error` at 502 on the images route → one retry; nothing was billed.
- `gateway_error` at 503 or 504 → wait `retry-after` (or ~2 s), retry; nothing was billed.
- `model_unavailable` at 404 → pick a real id from `GET /v1/models`; at 502 one retry.
- `content_policy`, `context_overflow`, `invalid_request`, `gateway_error` at 400 → never blind-retry; the request must change first.
