Error codes
Every error LUMEN returns to a client carries a stable LM-XXXX code, an
HTTP status, and a coarse type. The response body is always:
{ "error": { "code": "LM-1001", "message": "…", "type": "invalid_request" } }
The type is one of invalid_request, upstream_error, internal, or
client_cancelled. The gateway always distinguishes these situations and
never disguises one as another - in particular, an internal malfunction is
never reported as a misleading 401 (a lesson from OpenRouter outages), a
malformed upstream response is a 502, never a gateway 500, and a
client-initiated cancel (client_cancelled) is never counted as an internal
malfunction either (see LM-6xxx below).
Codes are stable: once assigned, a code keeps its meaning across releases. The
code prefix groups by cause: 1xxx request, 2xxx routing, 3xxx upstream,
4xxx auth/budget, 5xxx internal, 6xxx client-cancellation.
Request errors - LM-1xxx · type: invalid_request
| Code | HTTP | Meaning |
|---|---|---|
LM-1001 | 400 | Malformed or invalid request body / parameters. Also returned when a provider in strict mode is sent an unsupported-but-meaningful field it cannot honor (e.g. dimensions to Ollama, or response_format/seed/logprobs/parallel_tool_calls to a translated chat provider that has no native equivalent - see the chat-extras matrix), and when an input shape a provider cannot consume at all is sent (pre-tokenized token-id arrays to a text-only embed API: Cohere, TEI, Ollama, Jina, Voyage, Mistral). Both are rejected before any upstream call; the message names the field/shape and provider. Also covers every rejection of a config write under /admin/config* (ADR 012, both config_source modes) that is not specifically LM-1003 or LM-1004: a missing If-Match header on any mutating call, granular included; a candidate document that changes a restart-only boot-layer key (server.*, log_format, telemetry.*, auth.enabled, auth.db_path, the usage-log channel knobs, usage_events, config_source itself) - the message names the changed key(s) and says “edit the boot config file and restart”; in config_source = "db" mode, a candidate that carries any boot-layer key at all, even at its default value (the message names the key); a candidate that fails validation (bad TOML, an unknown field, an invalid virtual model such as a cycle, a missing target, a bad when or a mismatched remap, see Virtual models); a config that still uses the removed per-model fallbacks, [providers.models.rerank] or a rerank capability on a typesafe model, which names the model and points to lumen config migrate; PUT /admin/config/providers/{name} where the path name and the body’s own name disagree; a delete that would leave a virtual model reference dangling (DELETE /admin/config/providers/{name} of a provider, or DELETE /admin/config/virtual_models/{id} of a model, that a virtual model still targets), naming the dependent virtual model; PUT /admin/config/virtual_models/{id} where the path id and the body’s own id disagree; and a PUT /admin/config/auth body naming a boot-layer [auth] field (enabled, db_path, or a usage-log channel knob), since that route accepts only the 2 dynamic knobs (flush_interval_ms, retention_days). Also covers a choice remap rerank (a Jev virtual model, see Virtual models) over 255 documents or whose documents do not fit one upstream call, rejected before any upstream call. Also covers every POST /v1/decisions request-contract violation other than empty questions (that one is LM-2011), see Decisions: a body that is not JSON, or that is neither format (the message reads “send either OpenAI format (input, questions array) or TypeSafe format (state, questions object)”); an unknown top-level parameter in an OpenAI-format body; a null state, or a question with a missing or unknown type, missing instructions, or malformed criteria or choices or levels; and a request that no target in the chain can take, found before any upstream call (more questions than a target allows, such as Perplexity’s 128 or Cloudflare’s 64; more than 4 images for Cloudflare; a choice with one option for OpenAI or Ollama, or with more than 26 options or levels for Ollama; a noul without instructions for a typesafe model; a choice holding both "true" and true). The message names the offending question and, for a target limit, the provider; with several targets, an incompatible one is skipped and the error surfaces only when none is left. A provider that sets decisions_path or forward_unknown_fields while its kind is not typesafe, or a decisions_path that does not start with /, is refused at config load (the process does not boot) and, through the admin config writes, with this code. Also returned by the key and group admin routes for an invalid control-plane ref (ADR 015): an empty or over-128-character external_ref or account_ref, or an account_ref that is not a UUID while [usage_events] is configured; the message names the field. The same routes (create and PATCH) refuse a blank name, a negative budget_max, rpm_limit, tpm_limit or expires_at, and a budget_max above 1e12 USD, naming the field. Also returned by PUT /admin/webhooks for a receiver url whose host is internal (loopback, private, link-local, unique-local or otherwise reserved, a localhost name, or a hostname resolving to such an address or not resolving at all) and not listed in LUMEN_WEBHOOK_ALLOWED_HOSTS, and by PUT /admin/config or PUT /admin/config/webhooks when the write introduces or changes such a [webhooks] receiver; the message names the host and the variable. Also returned by the key, group and usage admin routes for an X-Lumen-Account-Ref header that is not a UUID (platform contract v2 section 8.3; a malformed value is refused, never read as “unscoped”), and on an account-scoped call for a key creation without group_id, a group creation whose account_ref differs from the header, a key PATCH setting group_id to null, and a group PATCH that sets account_ref. Also returned by the team key pool routes (/admin/groups/{id}/byok/...) for a provider kind that does not take a team key (only plain-API-key kinds do), a key shorter than 16 characters (after trimming) or longer than 1024 bytes, a label outside 1 to 64 characters, and an 11th key for one (group, provider). |
LM-1002 | 413 | Request body exceeded the configured size limit. |
LM-1003 | 404 | No route matches the request method and path. Returned by the router fallback for trailing-slash, extra-segment and other near-miss paths, so an unmatched request carries the same envelope as every other rejection instead of a bare, empty-body 404. The message names no path and discloses no route. The fallback sits outside the virtual-key auth layer, so an unmatched path answers 404 even when auth is enabled (it leaks no more than the bare 404 did); a matched /v1 route without a key is still LM-4004. Distinct from LM-2001, which means the HTTP route matched but the requested model id does not exist. Also returned by the granular config surface (ADR 012) for a provider name GET/PUT/DELETE /admin/config/providers/{name} does not recognize in the current document, and for a virtual model id GET/DELETE /admin/config/virtual_models/{id} (and .../{id}/plan) does not recognize in the current document, and for a section name GET/PUT /admin/config/{section} outside the fixed set (resilience, tokenizer, image_fetch, webhooks, auth; telemetry is boot-layer and has no granular route) - the same per-entity-404 style every other admin lookup in LUMEN uses. Also returned by POST /admin/providers/{name}/check for a provider name the live routing table does not contain (the check’s own verdict, including a rejected provider key, is data in a 200, never an error code). Also returned, with a message naming the id, by PATCH/DELETE /admin/keys/{id}, POST /admin/keys/{id}/rotate, POST /admin/keys/{id}/grant, PATCH/DELETE /admin/groups/{id} and POST /admin/groups/{id}/grant for an id that does not exist or is a soft-deleted tombstone (these were 400 LM-1001 before), and by PUT /admin/provider-keys/{name} for a provider name the current config document does not define (nothing is stored). GET /admin/groups/{id} answers the same 404 for an unknown or deleted group id. On a call carrying X-Lumen-Account-Ref (platform contract v2 section 8.3), a key or group of another account, and a key with no group, get this exact 404 too, so a scoped caller cannot tell a foreign id from one that never existed; so does a group_id of another account in a scoped key creation or PATCH. The team key pool routes (/admin/groups/{id}/byok/...) answer it for an unknown or out-of-scope group (same as the other group routes), an unknown provider and an unknown team key id. |
LM-1004 | 412 | The submitted If-Match no longer matches the current config document’s hash - the document changed since it was read, most often another operator’s apply landing first (or, in config_source = "file" mode, an external edit: a human or a GitOps sync). Applies to every mutating call under /admin/config* (ADR 012): the whole-document PUT /admin/config and every granular write (PUT/DELETE /admin/config/providers/{name}, PUT /admin/config/{section}) alike, in both config_source modes. The hash is source-verbatim in both modes - file mode: the whole config file’s bytes; DB mode: the stored config_versions document, or the empty document’s hash before the first PUT - so If-Match semantics are identical either way and a client cannot tell which mode it is talking to. The request is refused before anything is staged, patched or written; re-GET the resource for the current hash and content, then re-apply. Kept distinct from a missing/malformed If-Match header, which is a plain LM-1001 400: the console needs to tell “you sent no token” from “your token is stale” apart, since only the second case calls for a re-read-and-retry rather than a client bug fix. |
Routing & capability-request errors - LM-2xxx · type: invalid_request
| Code | HTTP | Meaning |
|---|---|---|
LM-2001 | 404 | The requested model id (foundation or virtual) was not found. |
LM-2002 | 400 | The model (foundation or virtual) exists but does not serve the requested capability. |
LM-2003 | 400 | An image content part was sent to a model without the image modality (chat vision M8, embeddings M9 and /v1/decisions). For a decisions request routed through a virtual model, the message names the leaf model that could not take the image, not the virtual id; targets without image are skipped first and this surfaces only when none is left. |
LM-2004 | 400 | A remote image URL was sent to a provider that only accepts inline base64 image data (chat vision M8). /v1/decisions accepts data: URLs only, so any http(s) image URL in either format is LM-2004. Checked before any upstream call for the primary route; if a fail-over reaches an image-incapable fallback further down the chain, the same code and status surface there too, naming the fallback provider - never the generic LM-3002 a translation failure would otherwise produce (GH #13). |
LM-2005 | 400 | A remote image URL was supplied to /v1/embeddings but server-side image fetching is disabled ([image_fetch] enabled = false). Inline the image as a data: URI or enable fetching (M9). |
LM-2006 | 400 | A remote image URL was rejected by a fetch guard (scheme, host/prefix allowlist, private-IP block, size cap, per-request count cap, or non-image content type). The specific reason is logged server-side, never returned (M9). |
LM-2007 | 502 | A permitted image fetch failed at the remote host (network error, timeout, or error status). type: upstream_error (M9). |
LM-2008 | 400 | A provider-native image source (Anthropic file_id, spelled anthropic-file:<id>; Gemini fileUri, a gs:// GCS URI or a Gemini Files API URI) was sent to a provider that cannot resolve it - the resolved primary provider must match the reference’s own provider. |
LM-2010 | 400 | A rerank request supplied no documents to score. |
LM-2011 | 400 | A decisions request (POST /v1/decisions) supplied empty questions: an empty array in the OpenAI format, an empty map in the TypeSafe format. Rejected before any upstream call; other decisions contract violations are LM-1001 (ADR 017, Decisions). The code is unchanged from the SystemOne era. |
LM-2012 | 4xx | The upstream rejected the input as longer than the model’s context window (classified from its error body, ADR 014). Names the provider; the HTTP status is the upstream’s (400, 403, 413 or 422). A virtual model can fail over on it with fallback_on = ["context_length"]; this code surfaces when no target absorbed it. |
LM-2013 | 4xx | The upstream refused the request on content policy, either with an error body (classified, ADR 014) or, for a choice rerank remap, with a refusal answer in a 200 response (ADR 017). Names the provider. The HTTP status is the upstream’s when it was a 4xx (usually 400 or 403), else 400 (a refused rerank choice is always 400). A virtual model can fail over on it with fallback_on = ["content_filter"]. |
Upstream errors - LM-3xxx · type: upstream_error
These always name the provider that failed. Retriable ones may be transparently retried on a fallback before surfacing.
| Code | HTTP | Meaning |
|---|---|---|
LM-3001 | 429 | An upstream provider rate limited the request. |
LM-3002 | 502 | An upstream provider returned an unparseable/malformed response. |
LM-3003 | 502 | An upstream provider returned an error status. Also a Cloudflare Workers AI decisions call answered HTTP 200 with success: false (the upstream errors[].message is kept out of the client response), and a Perplexity 413 (body over 32 MiB) or 504 (no answer within about 60 s; the 504 is retryable). For an upstream 4xx, the provider’s own message is usually in the preceding upstream returned an error log line (field upstream_error, same request_id; bounded, credentials redacted, never sent to the client). That line is absent when the upstream body carries no JSON message string. |
LM-3004 | 503 | No healthy upstream available (circuit open / fallbacks spent). |
LM-3005 | 504 | An upstream provider timed out. |
LM-3010 | 502 | An upstream stream ended prematurely (no terminator). |
LM-3011 | 504 | An upstream produced no first token within the first-token deadline. |
LM-3012 | 504 | The connection to an upstream could not be established within the connect timeout. |
LM-3013 | 504 | The whole request (all retries + fallbacks) exceeded the total timeout. |
LM-3020 | 503 | The provider’s circuit breaker is open and no fallback remained. |
LM-3021 | 429 | Every team (BYOK) key of the caller’s group for the provider is rate-limited or failing, and the group’s fallback to the platform’s keys is off. |
For LM-3001, LM-3020 (and LM-4002/LM-4003), a Retry-After value may be
advertised. The three timeouts (LM-3011 first-token, LM-3012 connect,
LM-3013 total) are distinct codes purely for debugging - see §6.4 and
docs/adr/005-resilience-execution.md.
How resilience shapes these codes
The 3xxx codes are what a client sees only after the resilience machinery
has given up. Before surfacing, a retryable failure (LM-3001 429,
LM-3003 5xx, LM-3005/LM-3012 timeouts) is retried with exponential
backoff, then the request fails over to the next target of the virtual model (its fallback_on triggers decide which failures move on, see Virtual models).
The mapping between a failure and the code that eventually surfaces:
LM-3020(503) - the primary’s circuit is open and no fallback remained. Skipping an open circuit is instant (no upstream call), and the response carries aRetry-Afterequal to the cooldown remainder. A link served from a group’s team keys (BYOK) has its own breaker, apart from the platform key’s, so one never opens the other.LM-3021(429) - the caller’s group brought its own keys for the provider and none is usable right now (rate-limited keys are benched formax(Retry-After, 30s), keys the provider rejects with 401/403 for 10 minutes). Decided by the gateway, so it is never retried and never counts against the circuit breaker. A fallback chain may move on under itsrate_limitedtrigger, but a later link only runs if the group also serves it with its own keys: with the fallback off, no link reaches the platform’s keys. When an earlier link of the chain already failed upstream (say the team key answered 503), a later link refused this way surfaces that earlier error instead, so the refusal never hides the real failure. The message tells the team to add a key or turn on the fallback; it never names a key.LM-3004(503) - every link in the fallback chain was tried and failed (retries exhausted or circuits open all the way down).LM-3013(504) - the total per-request deadline elapsed while retrying or failing over; it bounds all attempts together, so a slow chain fails here rather than hanging.LM-3011/LM-3012(504) - first-token and connect timeouts; each is a retryable failure on its own before it surfaces.
A hard upstream client error (a 4xx bad request) is never retried or failed
over - a different provider would reject it too - and surfaces immediately.
Whichever model ultimately served a successful request is reported in the
x-lumen-model-used response header.
Auth / budget errors - LM-4xxx · type: invalid_request
Codes pinned by the spec. Enforcement happens in memory, before any upstream call - a rejected request never leaks spend to a provider.
| Code | HTTP | Meaning |
|---|---|---|
LM-4001 | 402 | A hard budget the key is subject to is exhausted: either the key’s own budget_max or its budget group’s shared pool (ADR 009). Same code and status for both; the message text discloses the scope (“budget exceeded for this key” vs “budget exceeded for this key’s group”). |
LM-4002 | 429 | The key’s requests-per-minute quota was exceeded. |
LM-4003 | 429 | The key’s tokens-per-minute quota was exceeded. |
LM-4004 | 401 | Missing or invalid virtual key. Deliberately does not say why (unknown, disabled and expired are indistinguishable) so callers cannot probe key state. |
LM-4005 | 403 | The call carried X-Lumen-Account-Ref (an account-scoped control-plane call, platform contract v2 section 8.3) on a platform-only route: /admin/provider-keys/*, /admin/providers/*, /admin/webhooks*, /admin/config*, /openapi.json, GET /health/providers and GET /metrics (both need no key, but describe the whole platform: provider names and health, and metric labels across every account). Keys, groups and usage routes accept the header and are filtered by it. |
Internal errors - LM-5xxx · type: internal
| Code | HTTP | Meaning |
|---|---|---|
LM-5001 | 500 | Internal gateway malfunction. |
Internal errors return an opaque "internal error" message to the client; the
underlying detail is written only to the server logs, never the response.
Client-cancellation - LM-6xxx · type: client_cancelled
| Code | HTTP | Meaning |
|---|---|---|
LM-6001 | 499 | The client disconnected before the request completed; the upstream call was aborted. |
499 is the conventional “client closed request” status (nginx). The client
is normally already gone by the time this would be returned, so the status
exists for logs and metrics, not for anything a client reads. It is
deliberately kept out of both type: internal and the 5xx status class: a
client hanging up is not a gateway malfunction, and must never inflate the
internal-error metrics or alerts a real one would (issue #11, see
docs/adr/006-client-cancellation-error-code.md).
Two paths produce it. A cancellation surfacing mid-stream is emitted as a
terminal SSE error frame carrying this envelope. A client that simply
disconnects mid-stream never sees a frame at all, but the request’s
accounting record (usage_log.status and the
lumen_request_duration_seconds{status="499"} sample) is settled at 499
instead of being miscounted as a 200 success. A non-streaming disconnect
drops the request before any outcome is recorded and produces no sample.