error.message on the envelope, top-level message on the flat shape, error.provider_specific_fields.message on a chat gate rejection, detail on the bare shape and on one flat body — and a few carry no explanation at all. Fall back to the status plus the error string code.
Which shape you get depends on which layer turned you away, so don’t assume error is an object — or that it’s present at all. Endpoints we serve directly (/v1/images, /v1/videos, /v1/audio, and parts of /v1/flex/*) answer in either the OpenAI envelope or a flatter shape where error is a string code with the context beside it. Chat and completions run through our model router, which always answers in the envelope. And the 502s on GET /v1/flex/key and GET /v1/models return a bare detail string with no error key:
error.message is just the bare code, error.type and error.param come back as the string "None" rather than JSON null, error.code is the HTTP status as a string, and everything you’d actually want to show a user sits under error.provider_specific_fields. Reach for error.message there and you get the word unknown_model, not a sentence.
We’re converging these shapes. Until then, treat the status as the contract and every field in the body as optional — read error defensively (object, string, or absent), and when it’s an object, check provider_specific_fields before concluding there’s no detail.
What we return
TheWhere column matters: code is not consistent across endpoint families,
so switch on the status and treat code as a debugging aid, not a contract.
Where reads as: chat = /v1/chat/completions, /v1/completions; media =
/v1/images, /v1/videos, /v1/audio, with audio and video called out where
a code belongs to only one of them; flex = /v1/flex/*.
A code of null matches OpenAI, which reserves non-null codes for a narrow set. The 500 row is the exception: we don’t author those bodies, so treat its type and code as unstable.
‡ These two rows are derived from the gate they share with the chat 404 rather than captured from a live response, because provoking them needs an org that is out of credit or pinned to an unserved region. The status and the
provider_specific_fields payload are certain; treat the exact type and code strings as very likely rather than verified.400 — Bad request
Malformed body, or a field value we can’t serve.param names the offending field when we can identify one.
Prompt longer than the context window
Refused before any tokens are generated, so you aren’t billed. The message comes from the serving engine:message with its own exception class and leaves type and param as null. Read past the prefix — the sentence after it is the engine’s.
Read the limit from context_length on GET /v1/models (model discovery) rather than hard-coding it — a model’s window changes when we move it to different hardware.
The window covers the prompt and the completion. A
max_tokens on top of a prompt that nearly fills the window can still be refused.Unfetchable or undecodable image_url
A URL we can’t fetch, or bytes we can’t decode, is your fault rather than an outage — so it’s a 400, not a 500:
http(s) or data scheme. Genuine gateway failures on an image request still return 500, so treat this 400 as “fix the URL”.
Forced tool_choice the model didn’t honor
Pin tool_choice to required or a named function and get no tool call back, and we fail the request rather than return a toolless answer your agent loop would mistake for a result:
tool_choice="auto", or use a model that supports forced tool selection. It’s a 400 rather than a 5xx so a retrying agent changes the request instead of looping. See tool use.
401 — Unauthorized
Check theAuthorization: Bearer sk-… header. If the key looks right it may be revoked or expired — issue a new one from the dashboard.
/v1/models case in particular: reading error.code there gets you
None, not a string.
Some 401s also carry a doc_url pointing at the dashboard page that fixes them.
402 — Payment required
Four causes share this status;message tells them apart — credit exhausted, monthly spend cap reached, account on hold, or not provisioned for billing.
error.message is the bare string insufficient_balance, and the actionable content is under error.provider_specific_fields, as max_usage_cost_usd (what the request could cost) and key_remaining_usd (what’s left). Render those rather than looking for a sentence.
Top up on the billing page, or ask an org admin to raise the cap. The same key resumes working — nothing needs reissuing. See billing & quotas.
403 — Forbidden
Two causes, told apart bycode.
Region not served
Your org is pinned to a data region this endpoint doesn’t serve, so we decline rather than move your data out of it. On the media endpoints that’s a plain envelope:region_not_served under error.provider_specific_fields rather than at error.code.
Call the endpoint for your region. A region is immutable once set, so if the pinning is wrong contact support@flex.ai — it can’t be changed from the dashboard.
Blocked key
On/v1/models, a key an administrator has blocked returns 403 with type: "auth" and no code. Contact support@flex.ai — a blocked key can’t be unblocked from the dashboard.
404 — Not found
On chat and completions, an unrecognized model id is a gate rejection, so thedid_you_mean list arrives nested under error.provider_specific_fields:
suggested_model — a deterministic pointer, not a spelling guess.
A media model that exists but isn’t currently available uses the envelope with code: "model_not_found". Async jobs report job_not_found:
GET /v1/models — we require the bare model name, not a provider-prefixed path. For jobs, note that retention is 24 hours after completion, and a job belonging to another key reads as not-found by design so one key can’t probe for another’s work.
413 — Payload too large
A speech-to-text upload over the limit.max_size_mb always reports the limit, so read it from there rather than hard-coding a number:
Content-Length before reading the upload, and once after, in case the header understated it. The second check omits message, so this is one of the bodies where you need the error code and max_size_mb rather than a sentence.
Split or downsample the audio. See audio.
422 — Unprocessable content
The body parsed but a field is wrong.param names it, and code is
invalid_request_body:
detail rather than message:
429 — Too many requests
Over the per-key requests-per-minute ceiling.Retry-After. If you hit the ceiling consistently, request a tier upgrade from support@flex.ai. See concurrency & rate limits.
GET /v1/models throttles separately, on its own per-IP and global counters rather than
your key’s RPM, and reports it as type: "rate_limit" with no code. Cache the catalog
instead of polling it — it changes rarely.
The other 429: a flood limit at the edge
The shape above is the per-key limit, and it is the one you will meet in normal use. There is a second, much higher ceiling applied per source IP address at our edge proxy, before a request reaches the API at all. It exists to absorb floods and is set far above any legitimate client’s traffic — you should never see it. Because the proxy enforces it rather than the API, its 429 carries different headers, and the names are close enough to the ones above to be mistaken for them:
An edge 429 looks like this in full:
500 — Internal server error
An unexpected failure in our gateway or a model backend. We don’t shape these bodies, so readmessage and treat type/code as unstable.
Retry with backoff. We don’t support idempotency keys, so only retry requests you’re willing to have execute twice. Persistent 500s mean an incident — check status.flex.ai.
One 500 is ours and deliberate: pricing_unavailable on a media request means we
couldn’t resolve a price for it, so we declined to run it rather than serve work we
can’t bill. It’s safe to retry, and you were not charged.
502 — Bad gateway
OnlyGET /v1/flex/key and GET /v1/models. A component inside our gateway that
these two endpoints depend on was unreachable, rejected our own credentials, or answered
with a body we couldn’t use. We fail closed on all three rather than serve a result
we can’t stand behind — an unverified key, or a credit figure we’re not sure of.
This is the one status that returns a bare detail string with no error key, so
a client that reaches for error.message gets undefined here:
detail values are upstream LiteLLM unavailable, upstream LiteLLM auth unavailable, and upstream LiteLLM malformed response. They’re diagnostic, not a
contract — don’t branch on them.
Retry with backoff. Both endpoints that return a 502 are read-only lookups, so
retrying is always safe. Persistent 502s mean an incident — check
status.flex.ai.
503 — Service unavailable
code says which dependency: model_deactivated for a model an administrator disabled, backend_unavailable for a media backend we can’t reach, transcode_overloaded when audio processing is saturated, and service_unavailable / auth_service_unavailable when key verification itself is down (we fail closed rather than serve an unverified request).
model_deactivated, which won’t clear on a retry timescale. Fail over to another model from GET /v1/models instead.
504 — Gateway timeout
Only/v1/flex/*. A usage query ran longer than the gateway allows, reported as
type: "gateway_timeout" with code: "usage_timeout".