DGX LLM Chat Gateway

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>"
  }
}

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.

CodeTypeHTTPTitle
missing_authorizationauthentication_error401Authorization header is missing
invalid_authorizationauthentication_error401Authorization header is invalid
forbiddenpermission_error403Token lacks the required scope
rate_limit_exceededrate_limit_error429Rate limit exceeded
body_too_largerequest_too_large413Request body exceeds size limit
invalid_jsoninvalid_request_error400Request body is not valid JSON
missing_fieldinvalid_request_error400Required field is missing
invalid_fieldinvalid_request_error400Field value is invalid
model_not_in_allowlistinvalid_request_error400Requested model is not allowed
max_tokens_below_minimuminvalid_request_error400max_tokens is below the model's minimum
image_url_not_supportedinvalid_request_error400This model does not accept image URLs (use base64 data URI)
image_decode_errorinvalid_request_error400Could not decode the supplied image data
image_base64_invalidinvalid_request_error400Inline image base64 payload is not valid base64
model_not_vision_capableinvalid_request_error400This model cannot read images
tool_call_arguments_invalidinvalid_request_error400tool_call.function.arguments is not a valid JSON string
context_window_exceededinvalid_request_error400The prompt exceeds this model's maximum context length
upstream_bad_requestinvalid_request_error400Upstream provider rejected the request as invalid
embedding_input_too_largeinvalid_request_error400Embedding input exceeds the configured cap
agent_not_foundnot_found_error404Agent is not configured
agent_tool_not_registeredinvalid_request_error400Agent references a tool that is not registered
recursion_depth_exceededinvalid_request_error400Agent self-call recursion limit reached
index_not_foundnot_found_error404RAG index does not exist
document_not_foundnot_found_error404Document does not exist in this RAG index
session_not_foundnot_found_error404Agent session does not exist
session_agent_mismatchinvalid_request_error400Session belongs to a different agent
session_compact_conflictconflict_error409Compaction cannot proceed
runtime_not_runnableinvalid_request_error400Runtime does not support runs
ramteid_agent_disabledpermission_error403Ramteid Agent is not enabled on this gateway
ramteid_agent_repo_not_allowedpermission_error403Repository is not on this tenant's allowlist
ramteid_agent_run_not_foundnot_found_error404Run not found
agent_busyconflict_error409A run is already active for this work item
tenant_config_key_readonlyinvalid_request_error400Tenant config key is operator-controlled
tenant_config_invalid_valueinvalid_request_error400Tenant config value failed validation
image_not_foundnot_found_error404Image does not exist or has expired
memory_bucket_fullconflict_error409Memory bucket would exceed its cap
memory_confirm_requiredinvalid_request_error400Two-step-confirm required for public memory write via tool
system_prompt_too_largeinvalid_request_error400System-prompt content exceeds the per-level cap
system_prompt_version_not_foundnot_found_error404System-prompt version does not exist
system_prompt_current_lockedconflict_error409Refusing to delete the currently active system-prompt version
conversation_not_foundnot_found_error404Conversation does not exist
route_not_foundnot_found_error404Route not found
cloud_consent_requiredconflict_error409Cloud processing requires explicit document consent
content_blockedinvalid_request_error422Input blocked by content-safety guard
model_quota_exhaustedrate_limit_error429Model temporarily out of quota
openrouter_daily_quota_exhaustedrate_limit_error429openrouter daily quota exhausted
upstream_errorapi_error502Upstream provider returned an error
upstream_timeouttimeout_error504Upstream provider timed out
upstream_unavailableoverloaded_error503Upstream provider is unavailable
internal_errorapi_error500An internal error occurred
storage_errorapi_error500Conversation storage error

Authentication errors (401)

CodeCauseFix
missing_authorizationNo Authorization headerAdd Authorization: Bearer <token>
invalid_authorizationToken mismatchVerify 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)

CodeCauseFix
forbiddenToken is valid but lacks the required scope for the operationGrant 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)

CodeCauseFix
rate_limit_exceededPer-token bucket emptyBack off and retry; tune RATE_LIMIT_* server-side
model_quota_exhaustedModel temporarily out of quota / credits / daily budgetPick a different model alias or wait for the budget window to reset
openrouter_daily_quota_exhaustedOpenRouter-specific daily limit hit on a tenant aliasUse 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:

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)

CodeStatusCause
body_too_large413Body exceeds MAX_BODY_BYTES (default 32 MB)
invalid_json400Body unparseable / schema mismatch
missing_field400Required field absent
invalid_field400Field value bad type / range / enum
model_not_in_allowlist400model slug not whitelisted
max_tokens_below_minimum400Even after auto-floor, value still rejected upstream
image_url_not_supported400image_url.url is http(s):// — must be base64 data URI
image_decode_error400Data URI malformed
image_base64_invalid400Inline base64 payload not parseable (S-series Item 2)
tool_call_arguments_invalid400tool_calls[].function.arguments is non-parseable JSON-string (Layer 1)
upstream_bad_request400Upstream rejected as 4xx — propagated verbatim instead of opaque 502
embedding_input_too_large400/v1/embeddings input exceeds 256 elements or 8192 bytes/element
cloud_consent_required409A kind:"document" attachment would reach a cloud model without per-document consent
recursion_depth_exceeded400/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.

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)

CodeCauseFix
conversation_not_found/c1 — conversation doesn't exist or belongs to another user_idOmit conversation_id to start fresh; or list with GET /c1/conversations
route_not_foundPath/method combination unknownCheck /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.

CodeStatusCause
agent_not_found404Agent name not in the file-based registry
agent_tool_not_registered400agent.yaml tools[] names a tool the runtime registry lacks
index_not_found404RAG index id not present in the tenant-scoped registry
document_not_found404(index_id, doc_id) unknown in this tenant's index
session_not_found404Agent session id not in the tenant-scoped session-store
session_agent_mismatch400Session belongs to a different agent_name than the URL path
session_compact_conflict409Too few live messages to compact, or session already archived
image_not_found404Generated image id wrong, wrong tenant, or TTL lapsed
memory_bucket_full409Write would push a (scope_id, visibility, owner) bucket over its 16 KB cap
memory_confirm_required400Public-memory write via the LLM tool path needs a second confirm-token call
tenant_config_key_readonly400PUT /v1/tenant/config key is operator-controlled (Group B)
tenant_config_invalid_value400Value out of range / wrong type / unknown enum / model not allowlisted
system_prompt_too_large400System-prompt level content exceeds the per-level cap (default 8 KB)
system_prompt_version_not_found404Addressed (level, scope?, user?, version) tuple unknown
system_prompt_current_locked409Refusing 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)

CodeStatusCause
upstream_error502An upstream provider answered non-2xx — body inlined for debugging
upstream_timeout504Hit HTTP_TOTAL_TIMEOUT_SECS — most often gpt-image (100-180 s)
upstream_unavailable503TCP/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)

CodeCauseFix
internal_errorServer-side bug or panicRetry; check server logs with x-request-id
storage_errorSQLite read/write failedServer-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()