Error catalog
All error responses follow a stable envelope. The same shape is used by both
/v1/* and /c1/* endpoints.
{
"error": {
"type": "<category>",
"code": "<stable-code>",
"message": "<human-readable>",
"param": "<json-pointer>"
}
}
-
type— the top-level category. Since 2026-08-22 it is a pure function of the HTTP status, using the vocabulary of the Anthropic Messages API (the only reference API that documents itstypeset normatively — OpenAI's OpenAPI spec declarestype: stringwithout an enum). You can therefore derive one from the other, and a404always carriesnot_found_error:HTTP type400, 422 invalid_request_error401 authentication_error403 permission_error404 not_found_error409 conflict_error413 request_too_large429 rate_limit_error500, 502 api_error503 overloaded_error504 timeout_errorBefore that date we used our own values (
permission_denied,not_found,rate_limit_exceeded,upstream_error,internal_error) and the mapping contradicted itself — six404s and five409s carriedinvalid_request_error. If you branch ontype, update those five names. Branching on the HTTP status keeps working unchanged, and that is what the OpenAI and DeepSeek SDKs do. -
code— stable machine-readable identifier. Once published, codes are never repurposed. Adding new codes is non-breaking. -
message— human-readable english. -
param(optional) — JSON-Pointer to the offending field, e.g.messages[0].content[1].image_url.url.
Machine-readable catalog
Pull the full catalog as JSON from GET /errors (no auth):
curl -s https://dgx.spass.fun/errors | jq '.entries[0]'
{
"code": "missing_authorization",
"type": "authentication_error",
"http_status": 401,
"title": "Authorization header is missing",
"description": "All `/v1/*` and `/c1/*` endpoints require a Bearer token.",
"remediation": "Add `Authorization: Bearer <RUST_API_BEARER>` to the request.",
"typical_param": "headers.authorization"
}
Use this to generate stable error-handling code in your client without hand-typing constants.
Cross-link from JSON to docs
Each entry below has a stable HTML-anchor matching its code. The
machine-readable JSON at /errors can be cross-linked into
this page via /docs/errors#<code>. Example: a failed call returning
{"code":"image_base64_invalid"} jumps to
/docs/errors#image_base64_invalid. Anchors do not change once published.
Complete code list
The table below is generated from the catalog at serve time — it cannot fall
behind the code. Until 2026-08-22 this page and /errors were maintained by hand
and had drifted to 44 and 31 entries respectively, against 51 codes that actually
exist; twenty codes that are thrown in production appeared in neither.
The sections after it are hand-written deep dives for the codes that need one — they explain why, the table states what.
| Code | Type | HTTP | Title |
|---|---|---|---|
missing_authorization | authentication_error | 401 | Authorization header is missing |
invalid_authorization | authentication_error | 401 | Authorization header is invalid |
forbidden | permission_error | 403 | Token lacks the required scope |
rate_limit_exceeded | rate_limit_error | 429 | Rate limit exceeded |
body_too_large | request_too_large | 413 | Request body exceeds size limit |
invalid_json | invalid_request_error | 400 | Request body is not valid JSON |
missing_field | invalid_request_error | 400 | Required field is missing |
invalid_field | invalid_request_error | 400 | Field value is invalid |
model_not_in_allowlist | invalid_request_error | 400 | Requested model is not allowed |
max_tokens_below_minimum | invalid_request_error | 400 | max_tokens is below the model's minimum |
image_url_not_supported | invalid_request_error | 400 | This model does not accept image URLs (use base64 data URI) |
image_decode_error | invalid_request_error | 400 | Could not decode the supplied image data |
image_base64_invalid | invalid_request_error | 400 | Inline image base64 payload is not valid base64 |
model_not_vision_capable | invalid_request_error | 400 | This model cannot read images |
tool_call_arguments_invalid | invalid_request_error | 400 | tool_call.function.arguments is not a valid JSON string |
context_window_exceeded | invalid_request_error | 400 | The prompt exceeds this model's maximum context length |
upstream_bad_request | invalid_request_error | 400 | Upstream provider rejected the request as invalid |
embedding_input_too_large | invalid_request_error | 400 | Embedding input exceeds the configured cap |
agent_not_found | not_found_error | 404 | Agent is not configured |
agent_tool_not_registered | invalid_request_error | 400 | Agent references a tool that is not registered |
recursion_depth_exceeded | invalid_request_error | 400 | Agent self-call recursion limit reached |
index_not_found | not_found_error | 404 | RAG index does not exist |
document_not_found | not_found_error | 404 | Document does not exist in this RAG index |
session_not_found | not_found_error | 404 | Agent session does not exist |
session_agent_mismatch | invalid_request_error | 400 | Session belongs to a different agent |
session_compact_conflict | conflict_error | 409 | Compaction cannot proceed |
runtime_not_runnable | invalid_request_error | 400 | Runtime does not support runs |
ramteid_agent_disabled | permission_error | 403 | Ramteid Agent is not enabled on this gateway |
ramteid_agent_repo_not_allowed | permission_error | 403 | Repository is not on this tenant's allowlist |
ramteid_agent_run_not_found | not_found_error | 404 | Run not found |
agent_busy | conflict_error | 409 | A run is already active for this work item |
tenant_config_key_readonly | invalid_request_error | 400 | Tenant config key is operator-controlled |
tenant_config_invalid_value | invalid_request_error | 400 | Tenant config value failed validation |
image_not_found | not_found_error | 404 | Image does not exist or has expired |
memory_bucket_full | conflict_error | 409 | Memory bucket would exceed its cap |
memory_confirm_required | invalid_request_error | 400 | Two-step-confirm required for public memory write via tool |
system_prompt_too_large | invalid_request_error | 400 | System-prompt content exceeds the per-level cap |
system_prompt_version_not_found | not_found_error | 404 | System-prompt version does not exist |
system_prompt_current_locked | conflict_error | 409 | Refusing to delete the currently active system-prompt version |
conversation_not_found | not_found_error | 404 | Conversation does not exist |
route_not_found | not_found_error | 404 | Route not found |
cloud_consent_required | conflict_error | 409 | Cloud processing requires explicit document consent |
content_blocked | invalid_request_error | 422 | Input blocked by content-safety guard |
model_quota_exhausted | rate_limit_error | 429 | Model temporarily out of quota |
openrouter_daily_quota_exhausted | rate_limit_error | 429 | openrouter daily quota exhausted |
upstream_error | api_error | 502 | Upstream provider returned an error |
upstream_timeout | timeout_error | 504 | Upstream provider timed out |
upstream_unavailable | overloaded_error | 503 | Upstream provider is unavailable |
internal_error | api_error | 500 | An internal error occurred |
storage_error | api_error | 500 | Conversation storage error |
Authentication errors (401)
| Code | Cause | Fix |
|---|---|---|
missing_authorization | No Authorization header | Add Authorization: Bearer <token> |
invalid_authorization | Token mismatch | Verify RUST_API_BEARER matches server |
missing_authorization
Add Authorization: Bearer <RUST_API_BEARER> to the request.
invalid_authorization
The Bearer token did not match any token in the multi-tenant catalog. The
comparison is constant-time. The same code is also returned when an
SPASS-Tenant-Id header is presented that the matched token is not bound to.
Permission denied (403)
| Code | Cause | Fix |
|---|---|---|
forbidden | Token is valid but lacks the required scope for the operation | Grant the scope in auth/tokens.yaml or use a token that already has it |
forbidden
The bearer is valid, but the operation needs a scope the token does not have.
Permissions follow docs/adr/0003-api-key-permissions-model.md: scope-strings
(resource:action[:qualifier]) are the source of truth, optional roles seed
the default set, and extra_scopes/deny_scopes adjust per token. Grant the
needed scope (edit auth/tokens.yaml::tokens[].extra_scopes or assign a richer
role, then restart the chat-gateway) or use a token that already has it. The
rejection log line names the missing scope.
Rate-limit / Quota (429)
| Code | Cause | Fix |
|---|---|---|
rate_limit_exceeded | Per-token bucket empty | Back off and retry; tune RATE_LIMIT_* server-side |
model_quota_exhausted | Model temporarily out of quota / credits / daily budget | Pick a different model alias or wait for the budget window to reset |
openrouter_daily_quota_exhausted | OpenRouter-specific daily limit hit on a tenant alias | Use a fallback alias (Claude/Gemini); response carries Retry-After until next 00:00 UTC |
rate_limit_exceeded
Per-token rate limits (default 1 request/sec, 30 burst). Bucket is keyed on SHA-256 of the bearer, not IP.
model_quota_exhausted
Cut 2.23d (2026-05-04). The chosen model is temporarily unavailable for this tenant because some upstream budget — token quota, daily credit limit, or rate window — has been used up. The body is intentionally provider-agnostic per ADR 0006 v2: no upstream-name, no token counts, no billing URLs. Identical caller-facing shape to other rate-limit responses.
Fix options for the caller:
- Pick a different model alias (e.g. swap
openai-gpt-latest→anthropic-claude-opus-latest). The cross-vendor fallback chain (router_settings.fallbacksinlitellm/config.yaml) tries this automatically before emittingmodel_quota_exhausted; only if the entire cascade is out you see this code. - Wait for the budget window. Daily-credit windows typically reset at the start of the next UTC day.
Operator action: raise the per-tenant budget in tokens.yaml if affordable, or top up with the upstream provider directly.
openrouter_daily_quota_exhausted
Cut 2.32 (2026-05-16, CR-0005). Tenant-Alias (z.B. godelmann-gocreate-premium-gpt-text-premium) hat sein OpenRouter-Tageslimit erschöpft. Anders als beim generischen model_quota_exhausted (Cut 2.23d) ist hier der Provider explizit identifiziert weil der Alias direkt openrouter-gepinnt ist (kein cross-vendor-Fallback im Stack-Pfad).
Response-Shape:
HTTP/2 429
Retry-After: 32400
content-type: application/json
{
"error": {
"type": "rate_limit_error",
"code": "openrouter_daily_quota_exhausted",
"message": "GPT-Tageslimit über OpenRouter erschöpft. Reset um 00:00 UTC. Verfügbare Fallback-Modelle: …"
},
"dgx_code": "openrouter_daily_quota_exhausted",
"available_fallbacks": ["godelmann-gocreate-premium-claude-text-premium", "godelmann-gocreate-premium-gemini-text-premium"]
}
Caller-Pattern: auf dgx_code: "openrouter_daily_quota_exhausted" mappen, dem User "GPT-Tageslimit erreicht, probiere Claude oder Gemini" zeigen, Retry-After für ein Auto-Retry nach Mitternacht UTC nutzen.
Notices on successful responses (HTTP 200)
Some responses are complete but carry a note about how they came about — the
tool loop hit its cap, a hallucinated tool call was stripped, a safety fallback
was substituted. These are not errors and never appeared in this catalog as
such; until 2026-08-22 they travelled as a top-level dgx_code field in the
body, which made them a second, parallel code channel next to error.code.
They now travel on the SPASS-Notice response header (non-streaming) and as
named spass.* SSE events (streaming). See
Response headers for the value list.
Invalid request (4xx)
| Code | Status | Cause |
|---|---|---|
body_too_large | 413 | Body exceeds MAX_BODY_BYTES (default 32 MB) |
invalid_json | 400 | Body unparseable / schema mismatch |
missing_field | 400 | Required field absent |
invalid_field | 400 | Field value bad type / range / enum |
model_not_in_allowlist | 400 | model slug not whitelisted |
max_tokens_below_minimum | 400 | Even after auto-floor, value still rejected upstream |
image_url_not_supported | 400 | image_url.url is http(s):// — must be base64 data URI |
image_decode_error | 400 | Data URI malformed |
image_base64_invalid | 400 | Inline base64 payload not parseable (S-series Item 2) |
tool_call_arguments_invalid | 400 | tool_calls[].function.arguments is non-parseable JSON-string (Layer 1) |
upstream_bad_request | 400 | Upstream rejected as 4xx — propagated verbatim instead of opaque 502 |
embedding_input_too_large | 400 | /v1/embeddings input exceeds 256 elements or 8192 bytes/element |
cloud_consent_required | 409 | A kind:"document" attachment would reach a cloud model without per-document consent |
recursion_depth_exceeded | 400 | /a1 self-call arrived with SPASS-Caller-Depth ≥ 3 |
body_too_large
Bodies cap at MAX_BODY_BYTES (default 32 MB). Most often hit with very
large base64 image payloads.
invalid_json
The body could not be parsed as JSON. Validate against /openapi.json.
missing_field
Required field absent. The param field of the error envelope shows
which one.
invalid_field
A field's value did not match expected type/range/enum. See param for
which field.
model_not_in_allowlist
The model slug isn't whitelisted by the stack. Use one of the slugs
from /v1/info or /v1/models.
max_tokens_below_minimum
Some upstream cloud paths reject low values (max_output_tokens >= 16),
reasoning models need >= 200 to leave room for hidden reasoning tokens
before any visible content. The rust-api silently floors max_tokens
to the model's documented minimum and reports the adjustment via response
header:
spass-applied: max_tokens_floored=200
Only when even the floor would still be invalid does the error surface.
Read the per-model constraints.min_max_tokens from /v1/info:
curl -s -H "Authorization: Bearer $BEARER" https://dgx.spass.fun/v1/info \
| jq '.models[] | {alias, min_max_tokens: .constraints.min_max_tokens}'
image_url_not_supported
Cloud providers refuse to fetch arbitrary URLs server-side; local
inference doesn't either. The rust-api validates against
constraints.accepts_image_url before forwarding and rejects up-front
so you get a clear param pointer instead of an opaque 400 from the
upstream.
Encode your image as a base64 data URI:
B64=$(base64 -w 0 image.jpg)
curl -s -H "Authorization: Bearer $BEARER" \
-H "Content-Type: application/json" \
-d "{
\"model\": \"llama-4-scout\",
\"messages\": [{
\"role\": \"user\",
\"content\": [
{\"type\": \"text\", \"text\": \"Describe this\"},
{\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/jpeg;base64,$B64\"}}
]
}]
}" \
https://dgx.spass.fun/v1/chat/completions
image_decode_error
The base64-encoded data URI could not be parsed, the MIME type was missing,
or the decoded bytes were not a valid image. Verify format
data:image/<jpeg|png|webp|gif>;base64,<data>. Re-encode with
base64 -w 0 (no line wrapping).
image_base64_invalid
Pre-flight check: rust-api decodes every data:image/...;base64,<payload>
and rejects non-parseable base64 before forwarding upstream. Common
JS bug: passing a UTF-8 string through btoa corrupts non-ASCII bytes
— read as Uint8Array first. Output must be [A-Za-z0-9+/]+={0,2} only.
tool_call_arguments_invalid
OpenAI's tool-calling spec encodes function.arguments as a JSON-string
(e.g. "arguments":"{\"key\":\"value\"}"). rust-api parses each
argument-string with serde_json before forwarding upstream — non-parseable
JSON is caught here so the caller gets a clear diagnostic instead of an
opaque upstream Pydantic validation error. Cockpit-followup-6 (S.5)
defense-in-depth Layer 1.
upstream_bad_request
Upstream returned a 4xx (often 400 BadRequest) — typically caused by malformed payloads pre-flight didn't catch (corrupt base64, schema-validation error, content moderation flag). rust-api propagates the upstream status-code 1:1 instead of opaque 502, so the caller can distinguish "client-side-fixable" from "upstream-outage".
embedding_input_too_large
/v1/embeddings enforces two caps to protect the pooling-runner: at most
256 elements in the input array, and at most 8192 bytes (≈ 2k
tokens) per element. Both match the embedding service's max-model-len.
Split large documents into chunks (typical 512–1024 tokens) and send batches
of ≤ 256 inputs.
cloud_consent_required
GoCreate #4. One or more kind:"document" attachments would be sent to a
cloud model (leaving local infrastructure) without the user having consented
to uploading that specific document. Only enforced for tenants with
c1_cloud_doc_consent: true. The body carries dgx_code: "cloud_consent_required" and required_consents: [{content_hash, doc_name}].
Re-send with header SPASS-Cloud-Consent: <content_hash>[;persist][, ...]
(one entry per consented document; ;persist stores a permanent grant), or
route the documents to a local model.
recursion_depth_exceeded
An /a1/agents/<name>/chat request arrived with the SPASS-Caller-Depth
header at or above the configured maximum (3). The handler refuses a
further self-call because the request would form an /a1 → /v1 → /a1 chain.
In normal operation no caller sets this header — drop it, or find the
intermediary/proxy that is propagating it.
Not found (404)
| Code | Cause | Fix |
|---|---|---|
conversation_not_found | /c1 — conversation doesn't exist or belongs to another user_id | Omit conversation_id to start fresh; or list with GET /c1/conversations |
route_not_found | Path/method combination unknown | Check /openapi.json |
conversation_not_found
The supplied conversation_id was never persisted (or was deleted, or
belongs to a different user_id).
route_not_found
Path/method combination does not exist on this server.
Agent, RAG, session & config (/a1 + /v1 sub-resources)
Resource-level codes for the agent (/a1), RAG, session, image, memory,
tenant-config, and system-prompt surfaces. All share the same envelope.
| Code | Status | Cause |
|---|---|---|
agent_not_found | 404 | Agent name not in the file-based registry |
agent_tool_not_registered | 400 | agent.yaml tools[] names a tool the runtime registry lacks |
index_not_found | 404 | RAG index id not present in the tenant-scoped registry |
document_not_found | 404 | (index_id, doc_id) unknown in this tenant's index |
session_not_found | 404 | Agent session id not in the tenant-scoped session-store |
session_agent_mismatch | 400 | Session belongs to a different agent_name than the URL path |
session_compact_conflict | 409 | Too few live messages to compact, or session already archived |
image_not_found | 404 | Generated image id wrong, wrong tenant, or TTL lapsed |
memory_bucket_full | 409 | Write would push a (scope_id, visibility, owner) bucket over its 16 KB cap |
memory_confirm_required | 400 | Public-memory write via the LLM tool path needs a second confirm-token call |
tenant_config_key_readonly | 400 | PUT /v1/tenant/config key is operator-controlled (Group B) |
tenant_config_invalid_value | 400 | Value out of range / wrong type / unknown enum / model not allowlisted |
system_prompt_too_large | 400 | System-prompt level content exceeds the per-level cap (default 8 KB) |
system_prompt_version_not_found | 404 | Addressed (level, scope?, user?, version) tuple unknown |
system_prompt_current_locked | 409 | Refusing to hard-delete the currently active (is_current=1) version |
agent_not_found
The requested agent name is not in the file-based agent registry (loaded once
at process start). List with GET /a1/agents; add a YAML file and restart to
create one.
agent_tool_not_registered
An agent's tools[] lists a name the runtime tool-registry does not contain
(typo, or a tool renamed/removed since the agent was authored). Fix the
agent.yaml and restart.
index_not_found
The requested RAG index id is not present in the tenant-scoped registry.
Indices are created on first POST /a1/rag/indices/<id>/documents. List with
GET /a1/rag/indices.
document_not_found
The index exists for this tenant, but the requested document id is absent.
List with GET /a1/rag/indices/<id>/documents.
session_not_found
The requested session id is not in the tenant-scoped session-store. List with
GET /a1/agents/<name>/sessions or create a fresh one.
session_agent_mismatch
Sessions are pinned to the agent_name they were created with; the path's
<name> differs. Address the session under its original agent, or start a new
one under the desired agent.
session_compact_conflict
Either the session has too few live messages to compact
(≤ compact_keep_last_n), or it is already archived (read-only). For archived
sessions, follow the successor_session_id in the lineage view.
image_not_found
Generated images are stored per-tenant with a default 12 h TTL (up to 7 days
via ttl_hours). 404 means the id is wrong, the image belongs to a different
tenant, or the TTL lapsed. List with GET /v1/images.
memory_bucket_full
Each (scope_id, visibility, owner) bucket has a 16 KB cap (sum of
LENGTH(key)+LENGTH(value)). Delete/shorten entries via DELETE /v1/memory
or the memory_forget tool; GET /v1/memory/usage shows utilisation.
memory_confirm_required
Public-memory writes through the LLM tool path require an explicit second
tool-call with a confirm_token (60 s TTL) — defense against prompt-injection
that flips a private write to public. Re-invoke memory_remember with the same
arguments plus the token. REST writes (POST /v1/memory) do not need this.
tenant_config_key_readonly
PUT /v1/tenant/config accepts only Group C (UX/Operational) keys per ADR
0013. The requested key is Group B (security/billing/permissions) — those live
in tokens.yaml and require an operator yaml-edit + restart.
tenant_config_invalid_value
The value is out of range, wrong type, an unknown enum, or names a model not
in the tenant's effective allowlist. See the validation message; inspect
allowed ranges via GET /v1/tenant/config.
system_prompt_too_large
Each system-prompt level (tenant/scope/user) has an 8 KB content cap by
default (MAX_SYSTEM_PROMPT_BYTES_PER_LEVEL). The limit is per-level; three
levels stack to ≈ 24 KB.
system_prompt_version_not_found
The addressed (level, scope_id?, user_id?, version) tuple is unknown. List
versions via GET /v1/system-prompts/{level}/versions.
system_prompt_current_locked
Hard-delete refuses to remove the version with is_current=1. Roll back to a
different version (PUT /v1/system-prompts/{level}/current/{n}) or soft-delete
the current one first, then hard-delete.
Upstream (5xx)
| Code | Status | Cause |
|---|---|---|
upstream_error | 502 | An upstream provider answered non-2xx — body inlined for debugging |
upstream_timeout | 504 | Hit HTTP_TOTAL_TIMEOUT_SECS — most often gpt-image (100-180 s) |
upstream_unavailable | 503 | TCP/TLS to gateway/local-inference failed — check /readyz and docker ps |
upstream_error
A non-recoverable upstream 5xx. The server includes the (sanitised)
upstream message inline in message. Common causes: model rejected
oversize prompt, content moderation flag, provider-side outage.
upstream_timeout
Hit HTTP_TOTAL_TIMEOUT_SECS (default 600 s). Most often a slow image-
generation model (gpt-image regularly 100-180 s). Increase your client
timeout; check constraints.typical_response_seconds per model in
/v1/info. For gpt-image, set client timeout ≥ 240 s.
upstream_unavailable
Could not establish a TCP/TLS connection to the routing gateway or local
inference backend. Check /readyz and docker ps / docker logs.
Internal (500)
| Code | Cause | Fix |
|---|---|---|
internal_error | Server-side bug or panic | Retry; check server logs with x-request-id |
storage_error | SQLite read/write failed | Server-side: chown -R 65532:65532 data/sqlite && docker restart dgx-rust-api |
Recommended client pattern
import httpx
def call_gateway(payload: dict) -> dict:
r = httpx.post(
"https://dgx.spass.fun/v1/chat/completions",
headers={"Authorization": f"Bearer {BEARER}"},
json=payload,
timeout=240, # cover gpt-image worst case
)
if r.is_error:
body = r.json().get("error", {})
code = body.get("code", "unknown")
if code == "rate_limit_exceeded":
time.sleep(2); return call_gateway(payload)
if code == "image_url_not_supported":
# rewrite image_url to base64 and retry
...
raise GatewayError(code, body.get("message"), body.get("param"))
# honour silent adjustments
if applied := r.headers.get("spass-applied"):
log.info("server floored: %s", applied)
return r.json()