OBLETHdocs

Data-plane routes

Every route the data plane serves, with its authentication, body limits, errors and header handling.

13 min readUpdated for v2.4.0Edit on GitHub

The data plane listens on OBLETH_PROXY_LISTEN (default 0.0.0.0:8080). Every route except /health needs a credential.

Routes

MethodPathAuthWhat it does
GET/healthNoneLiveness check; returns ok
POST/v1/chat/completionsCredentialChat completions, streamed or not
POST/v1/completionsCredentialLegacy text completions
POST/v1/embeddingsCredentialEmbeddings
POST/v1/audio/transcriptionsCredentialSpeech to text, multipart
POST/v1/audio/translationsCredentialSpeech translation, multipart
POST/v1/audio/speechCredentialText to speech
GET/v1/audio/voicesCredentialThe voices a text-to-speech model's backend offers (?model=)
POST/v1/images/generationsCredentialImage generation
POST/v1/images/editsCredentialImage edits, multipart
POST/v1/images/variationsCredentialImage variations, multipart
POST/v1/videosCredentialCreates a video job; JSON or multipart
GET/v1/videosCredentialLists the caller's video jobs
GET/v1/videos/{id}CredentialVideo job status
GET/v1/videos/{id}/contentCredentialDownloads the video, streamed
DELETE/v1/videos/{id}CredentialDeletes a video job
POST/v1/meshesCredentialCreates a 3D mesh job; multipart or JSON
GET/v1/meshesCredentialLists the caller's mesh jobs
GET/v1/meshes/{id}CredentialMesh job status
GET/v1/meshes/{id}/contentCredentialDownloads the model (binary glTF), streamed
DELETE/v1/meshes/{id}CredentialDeletes a mesh job
POST/v1/rerank, /v1/rerankingCredentialForwarded to the named model
POST/v1/moderationsCredentialForwarded to the named model
POST/v1/responsesCredentialResponses API, translated to chat
POST/v1/messagesCredentialAnthropic Messages API, translated to chat
POST/v1/messages/count_tokensCredentialGateway token estimate for a Messages body
POST/v1/verdictsCredentialTyped verdicts from a chat model
POST/v1/search, /v1/search/{tool}CredentialWeb search through a search model; also at /search
GET/v1/search/toolsCredentialThe search tools the caller may use; also at /search/tools
GET/v1/models, /v1/models/CredentialLists the registry, filtered by the tenant's allowlist
GET/v1/models/{id}CredentialOne registry entry; an unclaimed id is forwarded upstream
GET/model/info, /v1/model/infoCredentialRegistry detail: prices, capabilities, health
any/mcp/{server}, /mcp/{server}/{*rest}CredentialMCP gateway
anyany other pathCredentialForwarded when the body names a registered model, else 404

Path handling

  • POST spellings. Repeated slashes collapse, a trailing slash is dropped, and the bare /responses, /messages, /messages/count_tokens and /verdicts map to their /v1 forms. The request is then served like the canonical path, with translation, boons and guardrails.
  • Path traversal. A .. segment, or %2e, %2f or %5c anywhere in the path, returns 400 before authentication.
  • Unknown endpoints. A path that is not a known endpoint and whose body names no registered model returns 404 unknown endpoint. It never reaches an upstream or the usage ledger.
  • Registered-model paths. Chat, completions, embeddings, responses, the three audio paths, the three image paths and POST /v1/videos must name a registered model in model (the form field for multipart). An alias is replaced by the canonical name before admission, budgets, caching, the allowlist and the ledger. A variant is too, with its boons added, its own response-cache entries and its name kept in the ledger's model_variant. Only chat takes part in auto routing.
  • Upstream URL. The path is appended to the endpoint's (or model's) api_base. Set api_base to the base ending in /v1; a doubled /v1 is dropped, and a base that already ends with the request path is used as is.

Authentication

The credential comes from Authorization: Bearer <credential>, or from x-api-key when there is no bearer token. It is an API key or, with identity tokens configured, a JWT. See Authentication.

ConditionStatusMessage
No credential401missing bearer token (missing api key on /v1/messages)
Unknown API key401invalid api key
JWT rejected401invalid token
Redis or identity backend unreachable503authentication backend unavailable
Verified JWT that cannot be mapped to a key503identity provisioning unavailable
Key disabled403api key disabled
Tenant not active403tenant is not active
Before the tenant's activation time403tenant is not active yet
After the tenant's expiry403tenant access has expired
Outside the tenant's weekly schedule403tenant is outside its scheduled access window

Route behaviour

  • Model listing. GET /v1/models answers from the registry without calling an upstream: every enabled model once, sorted by id, with id, object: "model", owned_by: "obleth", model_type, mode, quantization, tags, aliases, the model's own supports_* flags, boons and boon_details (whether each boon runs, and the helper model that does its work), and each variant as its own entry with variant_of and description. Staged and retired models and search models are left out; a deprecated entry adds status and its lifecycle fields. A request body is ignored. GET /v1/models/{id} returns the same entry for a name, alias or variant, including a retired model, and 404 for a staged model or one outside the tenant's allowlist. /model/info never returns api_base or api_key. See Model registry and Model lifecycle.
  • Multipart uploads. Audio, image edits and variations, and a video create with a reference frame are parsed: model is swapped for the upstream name, text fields are scanned and billed like JSON (image n), and file parts are forwarded unscanned. A multipart request gets one attempt, never a retry. See Images, audio and speech.
  • Video jobs. A create is billed cost_per_video once the job is recorded. Follow-ups go to the endpoint that holds the job, take no fairshare slot or budget, write no ledger row, and pass the upstream status through. By default a key sees only its own jobs. See Video generation.
  • Mesh jobs. The same as video jobs on /v1/meshes, billed the model's per-job price as request type mesh. A job is found only through the endpoint that created it. See 3D mesh generation.
  • Responses API. Body up to 32 MiB. store: true, background: true or a previous_response_id returns 400. See OpenAI-compatible API.
  • Messages API. Body up to 32 MiB; errors use the Anthropic envelope. An unknown model is served by the default model for Anthropic clients, else 404. count_tokens returns {"input_tokens": <n>} and is not admitted, budgeted or recorded. See Claude Code and Anthropic SDKs.
  • Verdicts. Needs a registered chat model or auto; one fairshare permit and one ledger row cover every question. See Verdicts.
  • Search. Body up to 64 KiB. Takes a slot in the search tool's pool with no token estimate, writes one free search ledger row, and answers in the Perplexity Search API shape. Any other route that names a search tool gets 400. See Search API.
  • Response cache. Checked before admission for a model with caching on and cache_ttl_secs above 0; never for video creates, tool loops or output guardrails. See Response cache.

Request pipeline

  1. Authenticate and gate the key and tenant.
  2. Read the body (up to 64 MiB), resolve the model, and check its lifecycle: a retired model is refused with 410 or answered by its replacement.
  3. Refuse endpoints the tenant's guardrails cannot enforce, then run boons and the input guardrails. Guardrails also run under x-obleth-boons: off.
  4. Check the response cache.
  5. Take a slot in the model's pool, waiting up to OBLETH_ADMISSION_TIMEOUT_SECS (default 60).
  6. Reserve the per-minute and term budgets in Redis.
  7. Forward with the model's upstream headers and the endpoint's api_key, retrying and failing over as configured.
  8. Stream the response back, reconcile the budgets and write the usage row.

A request that fails on every attempt is not billed. An upstream 4xx or 5xx without a usage block records zero tokens. Images bill n × cost_per_image, speech bills input characters × cost_per_character, and embeddings bill prompt tokens.

Error responses

Gateway errors on the OpenAI-shaped routes use this body:

{ "error": { "message": "token budget exceeded", "type": "obleth_gateway_error" } }
StatusMessageWhen
400invalid request pathPath traversal
400invalid JSON bodyUnparseable body on Responses, Messages or verdicts
400invalid multipart body, invalid multipart boundaryMalformed form
400model is requiredRegistered-model path without model
400request blocked by content policyInput guardrails blocked the request
400token-id prompts cannot be scanned by this tenant's guardrails; send textToken-id prompt under a block or redact input policy
400this tenant's input guardrails cannot scan requests to this path; use /v1/chat/completions, /v1/responses, or /v1/messagesUnrecognised POST path under a block or redact input policy
400this tenant's output guardrails read chat responses only; use /v1/chat/completions, /v1/responses, or /v1/messagesCompletions, transcription, translation or an unrecognised path under a block or redact output policy
400verdicts requires a chat model; …Verdicts against a non-chat model
400'<name>' is a search tool: call POST /v1/searchA non-search route named a search model
400`<field>` needs server-side response storage, …Responses request with store, background or previous_response_id
400request body too large or unreadableResponses body over 32 MiB
403model is disabledThe model is off
403model not permitted for tenantThe tenant's allowlist excludes the model
403api key term budget exhausted, tenant term budget exhaustedTerm budget spent
404model '<name>' is not registeredUnknown model name
404unknown endpointUnknown path, no registered model
404model '<id>' not foundGET /v1/models/{id} for a staged model or one outside the tenant's allowlist
410The model `<name>` was retired on <date>. Use `<replacement>` instead.The model is retired; code is model_retired, with replacement and retired_at
404no such video jobUnknown id, or another tenant's or key's job
413request body too largeBody over 64 MiB (32 MiB on Messages)
429token budget exceededPer-minute token budget spent
502upstream request failedEvery endpoint and attempt failed
502upstream response too largeA translated response over 32 MiB
502model has no usable upstream endpointVerdicts found no endpoint to call
502the upstream did not return a video job idCreate answered without a usable id
502the endpoint that created this video job is no longer configuredFollow-up to a removed endpoint
502could not list video jobs upstreamA backend failed during GET /v1/videos
503timed out waiting for model capacityNo slot within the admission timeout; sends Retry-After: 5
503no model is available to satisfy the auto requestauto found no eligible model
503scheduler unavailableFairshare could not admit
503budget check failed, key budget check failedRedis failed during budget reservation
503content policy scanner unavailableA scanner failed and the policy is not fail-open
503the model that created this video job is not availableThat model is disabled or gone
503video job lookup failedThe job record could not be read
503could not record the video job; it was cancelled, retry the requestJob created but not recorded
504upstream request timed outThe last attempt hit the upstream timeout

Upstream errors pass through with the upstream's status and body. For a stream that stops early, see When a stream is cut short.

On /v1/messages, errors use {"type": "error", "error": {"type", "message"}} with this error.type:

Statuserror.type
400invalid_request_error
401authentication_error
403permission_error
404not_found_error
413request_too_large
429rate_limit_error
502, 503, 504overloaded_error
otherapi_error

When a stream is cut short

A stream is cut when, before its last event, the upstream's connection breaks, the upstream goes silent past OBLETH_UPSTREAM_READ_TIMEOUT_SECS, or a chat or text completion stream closes with neither [DONE] nor a finish reason. The client then gets one error event, and the response is aborted, with no finish chunk, usage chunk or [DONE]:

data: {"error":{"message":"upstream stream ended before the response was complete (after 47 s)","type":"obleth_gateway_error","code":502}}

The seconds count from the start of the upstream call. OpenAI's SDKs raise the event as an APIError. Every stream ends a cut this way: one passed through from the upstream, each turn of the tool loop (including a follow-up or final turn that can't be dispatched), and the speculation boon's continuation. The translated Responses stream ends with response.failed instead, and the Messages stream with an error event, each carrying the message.

A cut is recorded as 502 and billed for what reached the client: the usage the upstream reported, else the streamed text at about 4 characters per token, else nothing. It is never billed the max_tokens estimate. A connection the upstream drops after [DONE] is not a cut.

An error event the upstream sends itself is forwarded once, and the gateway adds no event of its own. The request is recorded under the error's status (its code when that is an HTTP status, else 502) and billed the same way. When the tool loop or the speculation boon runs the stream, [DONE] follows the error event; otherwise the client gets the rest of the stream as the upstream sends it.

Headers

Client headers are forwarded upstream except host, content-length, connection, accept-encoding, authorization, x-api-key, x-obleth-boons and x-obleth-end-user. The model's upstream headers are added after them, so the operator's value wins. On /v1/messages, anthropic-beta and the beta query parameter are dropped.

HeaderDirectionPurpose
x-obleth-boonsRequestPer-request boon directive: off, lossy
x-obleth-session-id, x-session-idRequestConversation id for the ledger; see Conversations and sessions
x-obleth-effortRequestEffort hint for auto routing
x-obleth-end-userRequestThe end user, for a key with per-user fairshare
x-obleth-request-idResponseRequest id for request logs
x-obleth-auto-modelResponseThe model an auto request was served by
x-obleth-auto-reasonResponseWhy, when conversations keep their model
x-obleth-cache: hitResponseServed from the response cache
x-obleth-compressionResponsebefore=<n>;after=<n>;saved=<n> from the compression boon
x-obleth-boons-warningResponseA boon hit a non-fatal problem
x-accel-buffering: noResponseTells a buffering reverse proxy to pass tokens through
x-obleth-model-variantResponseThe variant or model family the request named
x-obleth-reasoning-effortResponseThe reasoning effort level served, or unsupported when it was removed
x-obleth-reasoning-effort-requestedResponseThe level asked for, when a fallback served another
x-obleth-drafted-byResponseThe drafter whose answer speculation shipped
x-obleth-model-status, x-obleth-model-replacementResponseA deprecated or retired model's status and replacement
Deprecation, Sunset, LinkResponseRFC 9745 and RFC 8594 dates and the successor-version link of a deprecated or retired model
x-obleth-redirected-fromResponseThe retired model a replacement answered for

MCP gateway

/mcp/{server} and its sub-paths take the data-plane credential and tenant gate, replace Authorization with the server's stored auth_header, and reverse-proxy the bodies unchanged, so streamable HTTP and SSE both work. Outcomes are counted in obleth_mcp_requests_total. See MCP gateway.

ConditionStatusMessage
Path traversal in the sub-path400invalid request path
Bad credential, key or tenant401, 403, 503As in Authentication
Server disabled403mcp server is disabled
Server not registered404mcp server '<name>' is not registered
Body over 16 MiB413request body too large
Upstream unreachable502mcp upstream request failed