API Reference - hugalafutro/model-hotel GitHub Wiki
Model Hotel exposes three API surfaces: the Proxy API (OpenAI-compatible, for client applications), the Admin API (for management and configuration), and a Health endpoint without authentication.
OpenAI-compatible endpoints that require a virtual key. The proxy covers chat completions plus the multimodal OpenAI API surface (embeddings, images, audio); everything else (fine-tuning, files, assistants, batches) is intentionally out of scope: Model Hotel is a proxy, not a full API gateway.
Authorization: Bearer <virtual-key>
Virtual keys use the sk- prefix (sk- plus 32 hex characters, e.g. sk-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6). Keys are created via the Admin API and are shown only once at creation time.
The x-api-key header (what the Anthropic SDKs send) carries the virtual key just as well, on every /v1 route rather than only /v1/messages. Authorization: Bearer is preferred when both are present.
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/v1/models |
GET | Virtual Key | List available models (OpenAI-compatible format) |
/v1/chat/completions |
POST | Virtual Key | Chat completion (streaming and non-streaming) |
/v1/messages |
POST | Virtual Key | Anthropic Messages API (translation + native passthrough) |
/v1/responses |
POST | Virtual Key | OpenAI Responses API (translation + native passthrough to OpenAI) |
/v1/embeddings |
POST | Virtual Key | Embeddings (JSON pass-through) |
/v1/rerank |
POST | Virtual Key | Document rerank (JSON pass-through, Cohere-style body) |
/v1/images/generations |
POST | Virtual Key | Image generation (JSON; SSE streaming via partial_images) |
/v1/images/edits |
POST | Virtual Key | Image edits (multipart upload) |
/v1/images/variations |
POST | Virtual Key | Image variations (multipart upload) |
/v1/audio/speech |
POST | Virtual Key | Text-to-speech (binary audio response; SSE via stream_format) |
/v1/audio/transcriptions |
POST | Virtual Key | Speech-to-text (multipart upload) |
/v1/audio/translations |
POST | Virtual Key | Speech translation to English (multipart upload) |
All endpoints share the same model routing (hotel/<model> failover or <provider>/<model> direct), virtual-key authentication, allowed_providers access control, rate limiting, circuit breaker, and request logging. See Multimodal Endpoints below.
# List available models
curl http://localhost:8081/v1/models \
-H "Authorization: Bearer $PROXY_KEY"
# Chat completion (with hotel routing for automatic failover)
curl -X POST http://localhost:8081/v1/chat/completions \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "hotel/glm-4.6", "messages": [{"role": "user", "content": "Hello!"}]}'
# Anthropic Messages API (point Claude Code or the anthropic SDK at the gateway;
# x-api-key is accepted alongside Authorization: Bearer)
curl -X POST http://localhost:8081/v1/messages \
-H "x-api-key: $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "hotel/claude-sonnet-4-6", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello!"}]}'
# OpenAI Responses API (point Codex CLI or the openai SDK's Responses client at the gateway)
curl -X POST http://localhost:8081/v1/responses \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "hotel/gpt-5.6-sol", "input": "Hello!", "stream": true}'
# Embeddings (multimodal endpoints support the same provider/model and hotel/ routing)
curl -X POST http://localhost:8081/v1/embeddings \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "OpenAI/text-embedding-3-small", "input": "Hello!"}'
# Speech-to-text (multipart upload)
curl -X POST http://localhost:8081/v1/audio/transcriptions \
-H "Authorization: Bearer $PROXY_KEY" \
-F model="OpenAI/whisper-1" -F [email protected]Returns the model list in OpenAI-compatible format.
The list is scoped to what the calling key may actually call: the key's own allowed_providers intersected with its owner account's provider cap, the same pair a chat request is routed through. A key restricted on neither side sees the whole catalogue, so this is a no-op unless an operator has deliberately restricted access. A hotel/ failover group stays listed while any entry in its priority order sits on a provider the caller may reach, and is described by the entry that would actually serve the request.
The listing is all-or-nothing: if either read behind it fails, the endpoint answers 500 rather than a 200 that is missing rows. A partial catalogue is a discovery answer a client routes on, and nothing in the OpenAI-compatible body can mark it as incomplete. A caller that hangs up mid-read gets 499 (client closed request), which the access log records; model listings are not metered, so no request-log row is written either way.
Each id is what a request names to reach that model: <provider>/<model> for a direct model (the provider name normalized, spaces as dashes) and hotel/<group> for a failover group. provider is the provider's name, or hotel for a group, whose other fields describe the entry that would serve it. Optional fields (context_length, max_output_tokens, name, description, modality and more) appear when the catalogue knows them.
Response:
{
"object": "list",
"data": [
{
"id": "OpenAI/gpt-4o",
"object": "model",
"created": 1234567890,
"owned_by": "openai",
"provider": "OpenAI"
},
{
"id": "hotel/gpt-4o",
"object": "model",
"created": 1234567890,
"owned_by": "openai",
"provider": "hotel"
}
]
}Chat completion endpoint supporting both streaming and non-streaming modes.
Request:
curl -X POST http://localhost:8081/v1/chat/completions \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "hotel/glm-4.6",
"messages": [{"role": "user", "content": "Hello!"}],
"stream": true
}'Request Body Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
model |
string | Yes | Model identifier in format hotel/<model> (failover) or <provider>/<model> (direct) |
messages |
array | Yes | Array of message objects with role and content
|
stream |
boolean | No | Enable streaming responses (default: false) |
temperature |
number | No | Sampling temperature |
max_tokens |
integer | No | Maximum completion tokens |
top_p |
number | No | Nucleus sampling parameter |
frequency_penalty |
number | No | Frequency penalty |
presence_penalty |
number | No | Presence penalty |
stop |
array/string | No | Stop sequences |
stream_options |
object | No | Streaming options (e.g. include_usage: true). Note: on a streaming request the proxy sets stream_options to {include_usage: true} so token usage is reported, replacing whatever the client sent. Provider types that reject the field (anthropic, anthropic-messages, google, cohere, opencode-go, opencode-zen) are left alone. |
Message normalization: if a message in messages carries tool_calls: [] (an empty array), the proxy removes the field before forwarding. Some clients serialize aborted or filtered tool-call turns this way, and strict providers reject the whole request with a 400 (Invalid 'messages[N].tool_calls': empty array), which permanently breaks any conversation carrying such a turn in its history. Non-empty tool_calls and all other message content pass through untouched.
OpenCode Go session header: OpenCode Go refuses any chat request that carries no x-opencode-session (400 MissingSessionID), because it pins routing and prompt caching to that id. Requests to a provider of type opencode-go therefore always leave the gateway with one, and no other provider type ever sees the header. A client that sends its own x-opencode-session has it forwarded as sent after the safety checks, so its conversation keeps one route and one cache; the value is only accepted when it can safely be a header (trimmed, at most 128 bytes, printable ASCII) and does not claim the gateway's own mh- namespace, and anything else is treated as absent. Absent, the gateway substitutes a stable opaque id derived from the virtual key (mh- plus 32 hex characters), so the same key always maps to the same session while neither the key nor its hash leaves the gateway. Traffic with no virtual key gets a fixed id per surface: admin chat, the retirement probe and the dashboard Test button each have their own. The value is not logged by the proxy.
OpenAI Responses API re-route: OpenAI's newest models refuse function tools combined with reasoning on /v1/chat/completions, and the pro tier (o1-pro, o3-pro, gpt-5.x-pro) is not served there at all. OpenCode Zen and OpenCode Go serve their GPT models the same way and refuse Chat Completions with a 400 "Model does not support this protocol". When a direct OpenAI or OpenCode candidate refuses for any of these reasons, the proxy retries the same request against that provider's /v1/responses, translated in both directions, and remembers the requirement per model so later requests go straight there.
This is invisible to the client: ordinary Chat Completions in and out, streaming included, with the model's reasoning summary surfaced as reasoning_content and token usage metered as usual. Every /v1/responses call sends store: false, so OpenAI retains no conversation state. See Failover & Hotel Routing.
Anthropic Messages route for OpenCode's Claude models: OpenCode Zen and OpenCode Go serve their Claude models (claude-*) over Anthropic Messages alone and refuse Chat Completions with the same 400. A chat request to one of them goes straight to the provider's /messages by name, no refusal needed, translated in both directions by the adapter that serves anthropic-messages providers and built with that type's parameter rules: reasoning_effort becomes extended thinking (with the same dialect self-heal and parameter learning), top_p is dropped, and the Go session header rides along. An Anthropic-in request (/v1/messages) to one of them is forwarded verbatim, as it is to Anthropic itself, and the dashboard's Test button probes /messages too.
Prompt caching on translated Messages routes: every chat request the gateway translates to Anthropic Messages (an anthropic-messages provider, a Claude model on OpenCode, a document request to Anthropic itself) leaves with Anthropic prompt-cache breakpoints placed where the cache hierarchy makes a reusable prefix: the last tool definition, the system prompt (sent as one text block, since only a block carries cache_control), and the last block of each of the final two turns. That is Anthropic's maximum of four and a Chat Completions client cannot add more. Without them Anthropic caches nothing and every turn of an agent loop re-reads its whole context at full price; with them the repeated prefix is read back at the cache-read price, and the counts come back to the client as usage.prompt_tokens_details.cached_tokens (plus Anthropic's own cache_read_input_tokens / cache_creation_input_tokens). Prompts under a model's minimum cacheable length are simply not cached. Cache writes are priced too: the tokens Anthropic reports as cache_creation_input_tokens carry its write premium (1.25x the input price) in the row's cost_usd, on top of the cache-hit price for the read ones; a 1-hour write an Anthropic-in client requests with cache_control.ttl (reported as cache_creation.ephemeral_1h_input_tokens) carries its 2x. A Messages endpoint that rejects the cache_control field itself as unknown (Anthropic never refuses the field; an operator-entered endpoint might) is learned per model from its 400 and served without breakpoints from then on, the same self-heal that learns the thinking dialect; Anthropic's own body-specific cache_control complaints do not count. The retirement probe follows the same learned facts and places breakpoints like served traffic; only the dashboard Test button sends none. Anthropic-in requests (/v1/messages) are forwarded verbatim with whatever cache_control the client placed.
Model Routing:
-
hotel/<model>- Failover routing (tries all providers that offer the model, moving to the next one on an upstream 5xx, 401, 402, 403, 404 or 499, and on 429 whilefailover_on_rate_limitis on, the default) -
<provider>/<model>- Direct routing to a specific named provider (no failover). The provider name is carried in the form routing uses, with every space replaced by a hyphen: a provider namedmy provideris addressed asmy-provider/<model>
Streaming Response Format:
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
The native Anthropic Messages API surface, so Claude Code and the anthropic SDKs can drive the gateway directly and fail over across every provider in a hotel/ group, not just Claude. Model routing is identical to the rest of the proxy: send hotel/<group> or <provider>/<model> in the model field. Authenticate with x-api-key (what Anthropic clients send) or Authorization: Bearer.
curl -X POST http://localhost:8081/v1/messages \
-H "x-api-key: $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "hotel/claude-sonnet-4-6",
"max_tokens": 1024,
"stream": true,
"messages": [{"role": "user", "content": "Hello!"}]
}'Two serving modes are chosen automatically, per failover attempt:
-
Native passthrough when the resolved candidate is an Anthropic-family provider: the original Messages body is forwarded to the provider's own
/v1/messagesand the response is streamed back verbatim, so extended-thinking blocks (thinking/signature_delta),cache_control, and fine-grained tool streaming survive end to end. -
Translation for every other provider: the request is converted to the OpenAI Chat Completions shape (system prompt, text, vision,
tools,tool_choice, and multi-turntool_use/tool_result), run through the same pipeline, and the OpenAI response, SSE stream, or error is converted back to the Anthropic wire format on the way out. A Gemini 3 candidate signs each tool call and refuses the next turn without the signature; thetool_useblock has no member for it, so the translation carries it inside the block'sidas an opaque suffix in the id alphabet (around a kilobyte), the client echoes the id back on thetool_useblock and thetool_resultas it does any id, and the translation recovers the signature on the way in. A later attempt served natively by an Anthropic provider gets the ids with the suffix stripped. Treattool_useids as opaque strings of any length.
Because the choice is per attempt, a single hotel/claude-* request served natively by Anthropic transparently fails over to a translated provider (e.g. another vendor offering the same model) if Anthropic is unavailable. The translated path drops thinking output (v1); use a provider that serves the model natively to preserve it. Streaming, tool use, multi-turn tool results, vision, document blocks (base64 documents become OpenAI file parts, text documents become text parts; a document behind a remote URL is dropped), and Anthropic-shaped errors are all supported. A file part carrying its document inline (a data: URI in file.file_data) and bound for Google AI Studio is served through the native generateContent route, since Google's OpenAI-compatibility layer rejects file parts; a file_id or a remote URL in file.file_data is not rerouted, because neither route can fetch it. Token usage is metered the same way as chat and request/response content is never logged.
The OpenAI Responses API surface, so Responses-only clients (Codex CLI, the openai SDKs' responses client) can drive the gateway directly and fail over across every provider in a hotel/ group. Model routing is identical to the rest of the proxy: send hotel/<group> or <provider>/<model> in the model field.
curl -X POST http://localhost:8081/v1/responses \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "hotel/gpt-5.6-sol",
"instructions": "Be terse.",
"input": [{"role": "user", "content": "Hello!"}],
"stream": true
}'Codex CLI configuration (~/.codex/config.toml):
[model_providers.hotel]
name = "Model Hotel"
base_url = "http://localhost:8081/v1"
env_key = "MODEL_HOTEL_KEY"
wire_api = "responses"Then codex -c model_provider=hotel -m hotel/<group> (or -m <provider>/<model>), with the virtual key in MODEL_HOTEL_KEY.
Two serving modes are chosen automatically, per failover attempt:
-
Native passthrough when the resolved candidate is OpenAI itself (
api.openai.com): the original Responses body is forwarded to OpenAI's own/v1/responseswith onlymodelrewritten, and the response or event stream comes back verbatim, so hosted tools (web_searchand the rest),include: ["reasoning.encrypted_content"],prompt_cache_key,text.verbosityand every other knob survive end to end. -
Translation for every other provider (an OpenAI-compatible relay of the
openaitype included): the request is converted to Chat Completions (instructionsanddeveloperturns become system messages,inputmessages withinput_text/input_image/ inlineinput_fileparts,function_call/function_call_outputhistory, functiontools,tool_choice,max_output_tokens,reasoning.effort,text.format), run through the same pipeline, and the answer is rendered back as a Response object or as the Responses event stream (response.created...response.output_item.added/response.output_text.delta/response.output_item.done...response.completed, every event carrying asequence_number). A function call closes with its fullargumentsonresponse.output_item.done, reasoning summaries stream asresponse.reasoning_summary_text.delta, a refusal streams as arefusalcontent part, alengthorcontent_filterstop becomesresponse.incompletewith the matchingincomplete_details.reason, and a failure ends the stream withresponse.failed. The Response object echoes the request'sinstructions,temperature,top_p,parallel_tool_calls,tool_choiceandmetadata;toolsis reported empty. A tool inside anamespacetool is offered to the provider as<namespace>__<name>and comes back as afunction_callitem withnamespaceand the barename; every tool name, namespaced or not, must be distinct. Non-streaming and streaming errors keep the OpenAI error envelope, which the Responses API shares.
The gateway keeps no conversation state, so every turn re-sends the transcript (what Codex does with store: false). Refused with a 400 naming the field, on both modes: previous_response_id, conversation, store: true, background: true, item_reference and compaction input items. Hosted tools other than web_search (file_search, code_interpreter, image_generation, computer, mcp, shell, apply_patch, local_shell), custom tools, file ids and audio parts are served only by OpenAI's own endpoint: a request carrying one is accepted when every resolved candidate is OpenAI itself, and refused with a 400 naming the member when any candidate would be translated, since no chat provider can execute them. A web_search tool is dropped on translated routes, because Codex advertises one to every provider by default and a provider behind a hotel/ group simply has no search. Prior reasoning items in input (encrypted reasoning from OpenAI) are dropped on translated routes: the model reasons fresh from the transcript. Because the mode is chosen per attempt, a hotel/ request served natively by OpenAI fails over to a translated provider when OpenAI is unavailable. Requests never hedge on this endpoint. Token usage is metered the same way as chat (the native mode reads the Responses usage block, cached tokens included) and request/response content is never logged.
The multimodal endpoints are transparent pass-through: the request is forwarded to the resolved provider with only the model field rewritten to the upstream model ID, and the provider's response is returned verbatim (JSON, SSE stream, or binary audio). The proxy extracts token counts from the usage object for metering; request and response content is never inspected or logged (see Privacy).
Failover applies the same way as chat: with hotel/<model> routing, an upstream 5xx, 401, 402, 403, 404 or 499 (and a 429 while failover_on_rate_limit is on) moves to the next provider in the group. Failover happens only before any response byte has been forwarded; once a binary or SSE stream starts, the proxy is committed to that provider.
curl -X POST http://localhost:8081/v1/embeddings \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "OpenAI/text-embedding-3-small", "input": ["Hello!", "World"]}'Standard OpenAI body (input, dimensions, encoding_format); response is the provider's embeddings list with usage.prompt_tokens metered.
curl -X POST http://localhost:8081/v1/rerank \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "Cohere/rerank-v3.5", "query": "what is a capybara", "documents": ["The capybara is a giant rodent.", "Paris is the capital of France."], "top_n": 2}'Cohere-style rerank body (query, documents, top_n), the de-facto standard shape also served by Jina, Voyage, and local TEI servers; the response (results with index and relevance_score) is returned verbatim. Cohere providers are routed to the native https://api.cohere.com/v2/rerank endpoint automatically (rerank is not part of Cohere's OpenAI-compatibility surface); any other provider receives the request at <base URL>/rerank. Cohere rerank models are auto-discovered alongside chat models, and hotel/<name> routes through a failover group like any other model once two providers list the same rerank id (see Failover and Hotel Routing).
Metering follows the provider's billing unit. Cohere bills per search unit (one query against up to 100 documents; a document over 500 tokens counts as several) and reports it as meta.billed_units.search_units: the proxy reads that figure, stores it on the request log row (search_units) and prices the row at the model's search_price_per_thousand (USD per 1,000 searches, from the built-in Cohere catalog or set by hand on the model), so rerank spend reaches the dashboard spend views and dollar budgets. Providers reporting usage.total_tokens (Jina, Voyage) are metered and priced per token as before. The virtual key's token limits are charged an estimate from the query and documents in either case, since a rerank answer carries no token usage.
curl -X POST http://localhost:8081/v1/images/generations \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "OpenAI/gpt-image-1", "prompt": "a hotel for AI models", "size": "1024x1024"}'All provider parameters (n, size, quality, response_format, style, output_format, partial_images, stream) pass through untouched. Streaming partial images (SSE) are forwarded verbatim.
multipart/form-data with image file(s), model, and the provider's other form fields. The proxy parses the form once to read model, then rebuilds it per failover candidate with the resolved model ID; file bytes are forwarded unmodified.
curl -X POST http://localhost:8081/v1/audio/speech \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "OpenAI/gpt-4o-mini-tts", "input": "Welcome to Model Hotel", "voice": "alloy"}' \
--output speech.mp3Gemini TTS models (gemini-2.5-flash-preview-tts and kin on Google AI Studio or Vertex AI express) have no speech route on Google's OpenAI-compatibility layer, so the proxy serves them through generateContent instead of passing the request through: the text becomes a request for an AUDIO response with a speechConfig naming the voice, and the PCM the model answers with is delivered as the audio the client asked for. What that means for the request:
-
response_formatmay bewav(the default when absent, in place of OpenAI's mp3) orpcm(raw 16-bit mono at the model's 24 kHz). The compressed formats (mp3,opus,aac,flac) need an encoder the gateway does not carry and are refused with a 400 naming the two it takes; in ahotel/group holding a model that does produce them, the request fails over to that model instead. -
voicetakes OpenAI's names (alloy,echo,fable,onyx,nova,shimmer,ash,ballad,coral,sage,verse,cedar,marin), each mapped to a Gemini prebuilt voice, or any Gemini voice name (Kore,Puck,Zephyr, ...) as it is. Absent meansKore. -
speedandinstructionshave no counterpart and are ignored; Gemini takes delivery style from the text itself ("Say cheerfully: ..."). -
stream_formatis not honoured; the whole clip is returned at once.
Usage is metered from the token counts the answer reports.
Except for the Gemini TTS models described above, the response is the provider's raw binary audio (Content-Type passed through, e.g. audio/mpeg), streamed with no buffering, and stream_format: "sse" responses stream through as SSE.
curl -X POST http://localhost:8081/v1/audio/transcriptions \
-H "Authorization: Bearer $PROXY_KEY" \
-F model="OpenAI/whisper-1" -F [email protected]multipart/form-data with file and model (plus optional language, response_format, temperature, etc.). Returns the provider's JSON ({"text": ...}) or alternate response_format output verbatim.
Upload size: request bodies are capped by the
MAX_REQUEST_SIZEenvironment variable (default 50MB, which accommodates OpenAI's 25MB audio limit plus multipart overhead). See Configuration.
Per-key rate limiting applies based on virtual key configuration. Returns 429 Too Many Requests when exceeded.
{
"error": {
"message": "rate limit exceeded",
"type": "rate_limit_error",
"code": 429
}
}The message names the limiter that refused: rate limit exceeded for a per-key or per-IP request cap, user rate limit exceeded for the caller's account-wide request cap, token rate limit exceeded and user token rate limit exceeded for the tokens-per-minute equivalents, and key budget exceeded or user budget exceeded (with the spend and the budget in the text) for a dollar budget, whose Retry-After runs to the end of the budget period.
Requires a signed-in identity: the admin token, or a session. What a caller may reach depends on its role and grants.
Authorization: Bearer <admin-token>
The admin token is generated on first startup (or taken from ADMIN_TOKEN) and its hash saved to <DATA_DIR>/admin-token (/data/admin-token in the Docker Compose setup). The plaintext is shown only once in the startup logs. With TOTP enabled the bare admin token no longer authorizes /api/* and must be exchanged via /api/totp/login.
A session works in its place. Every login front-end mints one: password (/api/auth/login), passkey, TOTP, OIDC or GitHub SSO, or the admin-token exchange (/api/auth/admin-exchange). The dashboard carries it in an HttpOnly cookie, and a cookie-authenticated POST/PUT/PATCH/DELETE must also send a matching X-CSRF-Token header (403 otherwise); a session token sent as Authorization: Bearer needs no CSRF header.
Roles and grants. The admin token and admin accounts reach everything. A non-admin user account reaches only what its grants open:
| Grant | Routes it opens |
|---|---|
chat |
/api/chat/*; GET /api/models and /api/models/cursor
|
usage |
/api/stats/*; GET /api/models, /api/models/cursor; GET /api/providers and /api/providers/{id}
|
logs |
GET /api/logs, /api/logs/cursor, /api/logs/{id} (own keys' rows only); request.* events on /api/events for its own keys |
models |
GET /api/models and /api/models/cursor
|
virtual_keys |
/api/virtual-keys (full CRUD on its own keys); GET /api/providers and /api/providers/{id}
|
Any signed-in identity, grants or not, may use /api/auth/me, /api/auth/password, /api/auth/sessions*, the per-user /api/auth/totp/* routes, /api/system, /api/version/latest and /api/events. Everything else is admin only: provider writes, discovery, quota (/usage, /balance, /account), model writes and tests, /api/logs/purge, app logs, audit, settings, alerts, failover groups, backups, users, config sync and fleet routes. A missing grant is a 403.
| Endpoint | Method | Description |
|---|---|---|
/api/providers |
GET | List all providers (with model counts and token totals) |
/api/providers |
POST | Create a provider |
/api/providers/{id} |
GET | Get provider details |
/api/providers/{id} |
PUT | Update provider |
/api/providers/{id} |
DELETE | Delete provider |
/api/providers/{id}/discover |
POST | Trigger manual model discovery |
/api/providers/{id}/usage |
GET | Get usage/quota info (Z.AI, Nano-GPT, OpenRouter, NeuralWatt, Kimi Code, MiniMax, OpenCode Go) |
/api/providers/{id}/balance |
GET | Get balance info (DeepSeek) |
/api/providers/{id}/account |
GET | Get account info (Ollama Cloud) |
/api/providers/discover-all |
POST | Trigger discovery for all enabled providers |
/api/providers/refresh-quotas |
POST | Refresh quota/balance data for all supported providers |
/api/discovery/changes/ack |
POST | Mark model changes recorded by background (scheduled/startup) discovery as seen, clearing the Models nav badge; returns the acked entries |
/api/discovery/status |
GET | Outstanding discovery claims and their flap counts (?review=1 adds the since-last-review numbers the modal shows) |
/api/discovery/{provider_id}/dismiss |
POST | Stop reporting a discrepancy for the given model_ids on one provider. Only already auto-disabled rows can be dismissed; 404 if none match |
/api/discovery/{provider_id}/unpin |
POST | Drop the operator pin from the given model_ids, handing them back to discovery's listing-based auto-disable; 404 if none carry a pin |
Response:
[
{
"id": "uuid",
"name": "OpenAI",
"base_url": "https://api.openai.com/v1",
"provider_type": "openai",
"masked_key": "sk-p...c5d6",
"enabled": true,
"autodiscovery_enabled": true,
"scheduled_disable_on": null,
"quota_reserve_percent": 0,
"max_in_flight": null,
"last_discovered_at": "2024-01-01T00:00:00Z",
"last_used_at": "2024-01-01T00:00:00Z",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"model_count": 15,
"total_tokens": 1234567,
"tokens_since": "2024-01-01T00:00:00Z"
}
]The plaintext API key is never returned; masked_key is a display-only preview. tokens_since (the timestamp of the oldest request log behind total_tokens) is omitted when the provider has no logged traffic, and a last_cap object is added only when the provider has answered an exhausted 429 since the process started. Non-admin callers see only their own traffic in total_tokens.

Request Body:
{
"name": "OpenAI",
"base_url": "https://api.openai.com/v1",
"provider_type": "openai",
"api_key": "sk-..."
}| Field | Type | Required | Constraints |
|---|---|---|---|
name |
string | Yes | 1-100 characters, unique in the routing form (see below), no /, and not hotel
|
base_url |
string | Yes | 1-500 characters, must use HTTPS unless ALLOW_HTTP_PROVIDERS=true
|
provider_type |
string | No | One of the known types (see Model Discovery). Omitted, it is derived from the vendor hostname |
api_key |
string | No | 1-500 characters (required for most providers, optional for Ollama, KoboldCPP, LMStudio, LocalAI, SGLang, TabbyAPI, OpenCode Zen, custom) |
Names must be routable. A request names a model as <provider>/<model> and
the proxy splits it at the first /, so a name containing / could never be
reached: it is refused with 400 and the code provider_name_slash. Every
hotel/... model goes to a failover group, so the exact name hotel is refused
with provider_name_reserved (Hotel or my hotel are fine: the prefix match is
case-sensitive and whole). Spaces are allowed; see below. The rule applies to new
names only: a provider that already has such a name keeps it, a save that resends
it unchanged is accepted, and fleet sync carries it to members unchanged.
Names are unique in the form routing uses. A <provider>/<model> id replaces
every space in the provider name with a hyphen, so my provider and
my-provider are one name: creating the second, or renaming a provider onto it,
returns 409. Renaming a provider to its own other spelling is allowed, since
the name it routes under does not change. An install that already holds such a
pair, from before the rule existed, refuses to start until one of them is
renamed, because either name may be the one clients use. The dashboard is down
at that point, so the rename happens in the database (the startup message names
the pair): docker compose exec db psql -U modelhotel -d modelhotel -c "UPDATE providers SET name = 'my provider 2' WHERE name = 'my-provider'", then start
again. Restoring a backup taken before the rule is the usual way to reach this.
Self-hosted providers must name their type. Omitting provider_type derives it
from the hostname only, so http://box:11434 becomes a generic OpenAI-compatible
provider rather than an Ollama one: no native discovery, no keyless waiver.
Scripts that used to rely on the old port detection (11434 / 5001 / 1234) need
"provider_type": "ollama" | "koboldcpp" | "lmstudio" | "localai" | "sglang" | "tabbyapi" added. When a self-hosted
type is named, the address is probed before the provider is saved and the
request fails with provider_type_mismatch, provider_type_unconfirmed or
provider_unreachable if the server does not answer as that type, so the server
must be running.
An Anthropic Messages endpoint must name its type too, for the same reason:
anthropic-messages describes a wire format, not a vendor, so no hostname
implies it. Named, the provider's chat traffic is translated to Anthropic's
/v1/messages instead of being sent to /v1/chat/completions, and models are
discovered from <base_url>/v1/models in Anthropic's shape. Omitted, the same
base URL becomes a generic OpenAI-compatible provider whose every request would
go to an endpoint that is not there. api.anthropic.com still derives
anthropic, which serves the OpenAI-compatible route by default. As with any
host that is not a known vendor's, a restricted install has to list the endpoint
in ALLOWED_PROVIDER_HOSTS before the provider can be created.
Response: 201 Created with provider object
Request Body: all fields optional for partial update. Accepts name, base_url, provider_type, api_key, enabled, autodiscovery_enabled, scheduled_disable_on, max_in_flight and quota_reserve_percent; POST accepts only the first four of those.
provider_type can be corrected here, which matters for a row the legacy hostname rules filed under the wrong type: re-adding the provider instead would cascade its models away. A new self-hosted type is probed exactly as on create, as is a changed base_url. scheduled_disable_on is an ISO date (YYYY-MM-DD) that must not be in the past, or null to clear it. max_in_flight caps the provider's concurrent upstream requests: null means no ceiling, and any number outside 1-10000 is a 400. quota_reserve_percent is the share of the provider's longest quota window (weekly, or monthly for OpenCode Go) kept back for use outside the gateway: 0 (the default) drains it fully, 10 to 90 in steps of ten pins the provider once that much of that window is left; null reads as 0, anything else is a 400. See Quota reserve.
Response: 204 No Content
Cascades to delete associated models and updates failover groups.
Triggers manual model discovery for a specific provider.
Response:
{
"discovered": 15,
"models": [...],
"diff": {
"added": [{"model_id": "gpt-4o-2024-11", "reason": "new_model"}],
"reenabled": [{"model_id": "o3-mini", "reason": "reappeared"}],
"disabled": [{"model_id": "gpt-4o-2024-05", "reason": "not_listed"}],
"updated": [{"model_id": "gpt-4o", "changes": [{"field": "input_price", "old": 5.0, "new": 2.5}]}],
"failover_deleted_groups": [
{"display_model": "o1-preview", "reason": "only 1 enabled provider (need 2+ for failover)", "provider_count": 1, "provider_names": []}
],
"failover_updated_groups": [
{"display_model": "glm-4.6", "removed_model_ids": ["uuid-old"], "added_model_ids": ["uuid-new"]}
],
"failover_disabled_groups": [
{"display_model": "my-custom-group", "effective_count": 1, "reason": "..."}
]
}
}The diff summarizes what the scan changed (all sections omitted when empty). Reasons are machine-readable codes: new_model, reappeared, not_listed. updated lists existing models whose metadata changed, one changes entry per field (input_price, output_price, input_price_cache, search_price, context_length), with old / new omitted when that side was unset. Failover groups of newly disabled models are re-synced as part of the scan; the resulting group changes appear in the three failover_* sections, failover_disabled_groups naming custom groups switched off because fewer than two routable members remain (their membership is kept). The dashboard shows this diff as a post-scan summary modal.
Returns usage/quota information for supported providers. Answers come from the stored quota snapshot (fetched live once on a cold miss), with the snapshot time in the X-Quota-Fetched-At header (RFC3339). A provider with no quota data to report (a NeuralWatt free-tier key, an OpenCode Go key with no subscription) answers 204 No Content; a dead or revoked provider key answers 424 Failed Dependency; a provider whose type serves a different kind (or none) is a 400. The same applies to /balance and /account.
Supported providers:
-
zai-coding(Z.AI) -
nanogpt(Nano-GPT) -
openrouter(OpenRouter - returns key balance) -
neuralwatt(NeuralWatt - returns quota; 404 from the upstream quota endpoint means a free-tier key and yields no data) -
kimi-code(Kimi Code - returns 5-hour/weekly quota, parallel-request limit, and membership tier) -
minimax(MiniMax - returns 5-hour/weekly Token Plan quota per model class) -
opencode-go(OpenCode Go - returns rolling/weekly/monthly subscription usage; a403 EntitlementErrorfrom the upstream usage endpoint means a key with no active Go subscription and yields no data)
Response (Z.AI example, abbreviated):
{
"code": 200,
"msg": "Operation successful",
"success": true,
"data": {
"level": "pro",
"limits": [
{
"type": "TOKENS_LIMIT",
"unit": 3,
"number": 5,
"usage": 800000000,
"currentValue": 12000000,
"remaining": 788000000,
"percentage": 1.5,
"nextResetTime": 1784491200000,
"usageDetails": [{"modelCode": "glm-4.6", "usage": 12000000}]
}
]
}
}The service decodes the Z.AI quota payload into its modelled fields (the ones shown) and re-serializes them; usageDetails is omitted when empty and nextResetTime is epoch milliseconds.
Response (Kimi Code example, abbreviated):
{
"user": {"userId": "u_123", "region": "global", "membership": {"level": "standard"}},
"limits": [
{"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"}, "detail": {"limit": "100", "remaining": "62", "resetTime": "2026-07-19T18:00:00Z"}},
{"window": {"duration": 10080, "timeUnit": "TIME_UNIT_MINUTE"}, "detail": {"limit": "700", "remaining": "410", "resetTime": "2026-07-24T00:00:00Z"}}
],
"parallel": {"limit": "4"},
"subType": "coding"
}The service decodes the Kimi Code /usages payload into its modelled fields and re-serializes them (fields it does not model are dropped). Besides those shown, the modelled fields are a top-level usage and totalQuota block, and every detail, usage and totalQuota block carries limit, used, remaining and resetTime, all strings, empty when Kimi omitted them; the dashboard derives 5-hour and weekly percentages from the limits array by matching each window's duration (300 minutes = 5h, 10080 minutes = weekly).
Response (OpenCode Go example):
{
"usage": {
"rolling": {"status": "ok", "percent": 12, "resetsAt": "2026-09-08T18:25:46.155Z"},
"weekly": {"status": "ok", "percent": 0, "resetsAt": "2026-09-14T00:00:00.155Z"},
"monthly": {"status": "ok", "percent": 3.5, "resetsAt": "2026-10-08T13:25:13.155Z"}
}
}The service re-serializes the modelled fields of the /usage payload; fields not listed here are dropped. percent is the share of the window already consumed (0 to 100), resetsAt is RFC3339, and status is documented only for "ok": any other value is OpenCode Go reporting the window as unusable. A key whose Go subscription has lapsed answers 403 EntitlementError, which is served as 204 No Content (no response body, no badge); the null exists only in the persisted snapshot payload behind it. Any other 403, and the 401 a revoked key answers, is served as 424 like every other dead credential.
Response (MiniMax example):
{
"model_remains": [
{
"model_name": "general",
"current_interval_total_count": 4500,
"current_interval_usage_count": 0,
"current_weekly_total_count": 45000,
"current_weekly_usage_count": 0,
"current_interval_status": 1,
"current_interval_remaining_percent": 100,
"current_weekly_status": 1,
"current_weekly_remaining_percent": 100,
"start_time": 1784473200000,
"end_time": 1784491200000,
"remains_time": 16420081,
"weekly_start_time": 1784073600000,
"weekly_end_time": 1784678400000,
"weekly_remains_time": 30820081
}
],
"base_resp": {"status_code": 0, "status_msg": "success"}
}The service decodes the MiniMax /token_plan/remains payload into its modelled fields, including base_resp, and re-serializes them (fields it does not model are dropped). Times are epoch milliseconds and *_remains_time a countdown in milliseconds to the window's reset; *_usage_count is what was used of *_total_count, while the *_remaining_percent fields are what remains, not what was consumed (some plans report all-zero counts, leaving the percentage as the only signal). MiniMax reports business errors (such as 2062 for "no active token plan subscription") inside an HTTP 200 response, so the dashboard checks base_resp.status_code rather than the HTTP status to decide whether quota data is available.
Returns balance information for supported providers.
Supported providers:
deepseek
Response:
{
"is_available": true,
"balance_infos": [
{"currency": "CNY", "total_balance": "100.50", "granted_balance": "0.00", "topped_up_balance": "100.50"}
]
}DeepSeek's /user/balance payload, re-serialized from its modelled fields. The balances are strings, one entry per currency.
Returns account information for supported providers.
Supported providers:
ollama-cloud
Response:
{
"id": "...",
"email": "[email protected]",
"name": "...",
"plan": "pro",
"customer_id": {"string": "cus_...", "valid": true},
"subscription_id": {"string": "sub_...", "valid": true},
"subscription_period_start": {"time": "2026-09-01T00:00:00Z", "valid": true},
"subscription_period_end": {"time": "2026-10-01T00:00:00Z", "valid": true},
"suspended_at": {"time": "0001-01-01T00:00:00Z", "valid": false}
}Ollama Cloud's /api/me payload, re-serialized from its modelled fields. Nullable values arrive as {string|time, valid} pairs, valid: false meaning null. No credit or usage figure is reported.
Triggers discovery for all enabled providers.
Response:
{
"results": [
{"provider_name": "OpenAI", "discovered": 15, "diff": {"added": [{"model_id": "gpt-4o-2024-11", "reason": "new_model"}]}},
{"provider_name": "Anthropic", "discovered": 5, "diff": {}},
{"provider_name": "Broken", "discovered": 0, "error": "connection refused"}
],
"succeeded": 2,
"failed": 1,
"discovered": 20
}Each successful result carries the same per-provider diff as the single-provider endpoint (omitted when the provider's scan failed).
Refreshes quota/balance information for all providers that support it.
Response:
{
"results": [
{"provider_name": "Z.AI", "provider_type": "zai-coding", "refreshed": true},
{"provider_name": "DeepSeek", "provider_type": "deepseek", "refreshed": true}
],
"refreshed": 2,
"failed": 0,
"skipped": 5
}| Endpoint | Method | Description |
|---|---|---|
/api/models |
GET | List all models (optional ?provider_id= and ?provider_enabled=true|false filters; the latter scopes to rows whose provider is enabled, i.e. what /v1/models advertises) |
/api/models/cursor |
GET | Cursor-paginated model listing for large catalogues; accepts the same provider_enabled filter and reports filter-wide total, enabled_total and parked_total
|
/api/models/bulk-delete |
POST | Delete several models in one call (admin only) |
/api/models/{id} |
PATCH | Update model (enable/disable, edit metadata) |
/api/models/{id} |
DELETE | Delete model permanently |
/api/models/{id}/test |
POST | Test a model by sending a minimal prompt |
Cursor (keyset) pagination walks the list by passing the previous response's next_cursor back instead of an offset, so a page stays stable while rows are inserted ahead of it.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
provider_id |
UUID | Filter by provider UUID |
provider_enabled |
true / false
|
Filter on the owning provider's enabled flag. true returns the rows the proxy can list on /v1/models (subject to the model's own enabled); false returns rows parked under a disabled provider. Any other value is a 400. Omit for both. |
Response:
[
{
"id": "uuid",
"model_id": "gpt-4o",
"name": "GPT-4o",
"display_name": "GPT-4o",
"provider_id": "uuid",
"provider_name": "OpenAI",
"provider_enabled": true,
"capabilities": "{\"streaming\":true,\"vision\":true,\"reasoning\":false,\"audio_input\":false}",
"context_length": 128000,
"max_output_tokens": 16384,
"input_price_per_million": 5.0,
"output_price_per_million": 15.0,
"input_price_per_million_cache_hit": 2.5,
"search_price_per_thousand": null,
"price_sources": {"input": "provider", "cache_hit": "modelsdev", "output": "provider"},
"owned_by": "openai",
"description": "Most capable model",
"params": "{\"temperature\":0.7}",
"modality": "text",
"input_modalities": "[\"text\",\"image\"]",
"output_modalities": "[\"text\"]",
"enabled": true,
"disabled_manually": false,
"price_customized": false,
"limits_customized": false,
"capabilities_customized": false,
"capabilities_editable": false,
"provider_type": "openai",
"created_at": "2024-01-01T00:00:00Z",
"last_seen_at": "2024-01-01T00:00:00Z"
}
]capabilities, params, input_modalities and output_modalities are JSON documents carried as strings ("{}" / "[]" when unknown), so a client parses them a second time. search_price_per_thousand is the rerank price per 1,000 search units, null for token-billed models. disabled_manually marks a model an operator switched off (as opposed to one discovery disabled); price_customized, limits_customized and capabilities_customized mark prices, context limits and capabilities pinned against discovery. provider_type is the stored type of the model's provider. capabilities_editable says whether the capabilities can be set by hand (a custom or self-hosted provider).
price_sources says where each stored price came from, keyed by price field: provider (the provider's own listing), catalog (Model Hotel's embedded override), modelsdev (enrichment) or manual (an operator edit). A key is absent while that price is unset or was stored before sources were recorded.

Request Body: (all fields optional, but at least one is required: an empty body is a 400)
{
"display_name": "Custom Name",
"context_length": 128000,
"max_output_tokens": 16384,
"input_price_per_million": 5.0,
"input_price_per_million_cache_hit": 2.5,
"output_price_per_million": 15.0,
"search_price_per_thousand": 2.0,
"price_customized": true,
"limits_customized": true,
"capabilities": {"streaming": true, "vision": true, "tool_calling": true},
"capabilities_customized": true,
"enabled": true
}Validation:
-
display_name: 1-128 characters (empty clears it back to the discovered name) -
context_length: 256-2000000 -
max_output_tokens: 1-128000 -
input_price_per_million: 0-1000 -
input_price_per_million_cache_hit: 0-1000 -
output_price_per_million: 0-1000 -
search_price_per_thousand: 0-1000 -
limits_customized: boolean; any edit ofcontext_lengthormax_output_tokenspins both against discovery, andfalseclears the pin and both limits so the next scan refills them -
capabilities: object of capability flags (streaming,vision,video_input,audio_input,reasoning,tool_calling,parallel_tool_calls,structured_output,pdf_upload), a custom or self-hosted provider's models only (capabilities_editablein the response says which); replaces the stored flags whole (an omitted flag isfalse; the dashboard, which has no streaming toggle, sends streaming back as stored) and pins them against discovery -
capabilities_customized: boolean, a custom or self-hosted provider's models only;falseclears the pin so the next scan writes the server's reading again.capabilities, orcapabilities_customized: true, on another provider type's model is a400with codecapabilities_not_editable; the unpin is taken on any type, so a pin left behind by a provider type change can still be cleared -
price_customized: boolean; marks the prices as operator-set so discovery enrichment leaves them alone. An edited price is recorded asmanualin the model'sprice_sources; unpinning clears the prices and their sources so the next scan writes both afresh.
Response: 204 No Content
Tests a model by sending a minimal prompt and measuring response. A rerank model is probed on its own route instead (one document, top_n: 1), and the answer reports ranked_results in place of response; a 200 that ranks nothing is reported as a failure, the verdict the proxy reaches for the same body. Every probe writes one request log row under virtual_key_name: internal, with the family it was sent on in endpoint_type, and priced like live traffic (cost_usd; a rerank probe also records its search_units).
Response:
{
"success": true,
"streaming": true,
"duration_ms": 234,
"ttft_ms": 123,
"response_header_ms": 98,
"response": "Hi"
}streaming says whether the probe streamed; ttft_ms and response_header_ms are omitted when not measured.
For a rerank model:
{
"success": true,
"streaming": false,
"duration_ms": 296,
"response": "",
"ranked_results": 1
}On error:
{
"success": false,
"streaming": false,
"duration_ms": 5000,
"error": "HTTP 401: Invalid API key"
}| Endpoint | Method | Description |
|---|---|---|
/api/failover-groups |
GET | List all failover groups (with token counts) |
/api/failover-groups |
POST | Create a failover group |
/api/failover-groups/sync |
POST | Re-sync all groups with current discovery data |
/api/failover-groups/candidates |
GET | List candidate models available for failover groups |
/api/failover-groups/circuit-breaker-status |
GET | Current circuit breaker state per provider (cached briefly; ?detail=1 adds per-circuit detail) |
/api/failover-groups/circuit-breaker/{provider_id}/reset |
POST | Force one provider's circuits back into rotation; ?model=<upstream id> scopes it to one circuit |
/api/failover-groups/{id}/circuit-breaker/reset |
POST | Force every circuit behind one failover group's entries back into rotation, on this member |
/api/failover-groups/circuit-breaker/reset |
POST | Force every tracked circuit back into rotation (API only, no UI control) |
/api/failover-groups/by-model/{model_uuid} |
GET | Find which failover group a model belongs to |
/api/failover-groups/{id} |
GET | Get group details with priority order |
/api/failover-groups/{id} |
PUT | Update priority order, enable/disable entries, rename |
/api/failover-groups/{id} |
DELETE | Delete a failover group |
Response:
{
"groups": [
{
"id": "uuid",
"display_model": "glm-4.6",
"display_name": "GLM 4.6 Failover",
"description": "Primary failover group",
"group_enabled": true,
"auto_created": false,
"auto_disabled": false,
"entries": [
{
"model_uuid": "uuid",
"model_id": "z-ai/glm-4.6",
"provider_id": "uuid",
"provider_name": "OpenRouter",
"display_name": "GLM 4.6",
"enabled": true,
"model_enabled": true,
"provider_enabled": true,
"disabled_manually": false,
"context_length": 200000,
"owned_by": "z-ai"
}
],
"total_tokens": 123456,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
],
"last_synced_at": "2024-01-01T00:00:00Z"
}auto_disabled marks a group switched off because it fell below two routable members (by sync or by the dashboard's floor cascade), not by an operator. An entry's disabled_manually tells an operator-disabled model from one discovery disabled.

Request Body:
{
"display_model": "glm-4.6",
"display_name": "GPT-4o Failover",
"description": "Optional description",
"entry_ids": ["uuid-1", "uuid-2", "uuid-3"]
}| Field | Type | Required | Description |
|---|---|---|---|
display_model |
string | Yes | 1-128 characters, must be unique |
display_name |
string | No | 1-128 characters |
description |
string | No | 0-500 characters |
entry_ids |
array | Yes | Array of model UUIDs in priority order (minimum 2) |
Response: 201 Created with failover group object
Request Body: (all fields optional)
{
"display_name": "Updated Name",
"display_model": "glm-4.6",
"description": "Updated description",
"group_enabled": true,
"priority_order": ["uuid-2", "uuid-1", "uuid-3"],
"entry_enabled": {
"uuid-1": true,
"uuid-2": false,
"uuid-3": true
}
}| Field | Type | Description |
|---|---|---|
display_name |
string | 1-128 characters |
display_model |
string | 1-128 characters, must stay unique across groups: the hotel/ name clients route on |
description |
string | 0-500 characters |
group_enabled |
boolean | Enable/disable entire group |
priority_order |
array | New priority order of model UUIDs |
entry_enabled |
object | Map of model UUID to enabled state |
floor_disabled |
boolean | Marks a group_enabled: false as the dashboard's floor cascade (a member toggle left fewer than two routable members) rather than an operator's choice. The server re-checks the count and ignores the flag on a viable group; only such a disable is stamped auto_disabled
|
Validation: an active group must keep at least one enabled entry. Turning a group on (the off to on transition only) additionally requires at least 2 routable members, meaning entries whose model and whose provider are both enabled; short of that the request is a 400.
Find which failover group contains a given model UUID.
Response:
{
"id": "uuid",
"display_model": "glm-4.6",
"position": 1,
"total_entries": 3
}Returns 404 if the model is not found in any failover group.
Re-synchronizes all failover groups with current model database state.
Response:
{
"deleted_groups": [],
"updated_groups": [{"display_model": "glm-4.6", "removed_model_ids": ["uuid-old"], "added_model_ids": ["uuid-new"]}],
"purged_entries": [{"group_display_model": "glm-4.6", "pruned_model_ids": ["uuid-gone"]}],
"disabled_groups": [{"display_model": "my-custom-group", "effective_count": 1, "reason": "..."}],
"sync_errors": []
}deleted_groups is always present; the other four are omitted when empty.
Returns available models that can be added to failover groups.
Response:
[
{
"model_uuid": "uuid",
"model_id": "gpt-4o",
"provider_id": "uuid",
"provider_name": "OpenAI",
"display_name": "GPT-4o",
"context_length": 128000,
"owned_by": "openai"
}
]Clears one provider's circuit, returning it to rotation immediately instead of waiting out the cooldown. Use it when you have fixed the provider yourself (rotated a dead key, restarted a local runtime, topped up a spent plan) and do not want to wait for the breaker to find out.
Response:
{
"provider_id": "uuid",
"previous_state": "open",
"reset": true
}| Field | Type | Meaning |
|---|---|---|
provider_id |
string (UUID) | The provider whose circuit was cleared |
previous_state |
string |
closed, open or half-open: what the breaker reported a moment before the reset |
reset |
bool |
false when there was nothing to clear, so the response can say "no change" instead of implying a recovery |
Resetting an untracked or already-closed provider is a successful no-op (previous_state: "closed", reset: false), not an error: the breaker only tracks providers it has routed, so "no circuit" and "closed circuit" are the same healthy state. The reset clears state, failure count and any quota pin together; if the provider is still broken the circuit reopens after circuit_breaker_threshold failures.
?model=<resolved upstream model id> scopes the reset to that one circuit and leaves the provider's other circuits, and the charges they have legitimately accrued, alone; the response then carries "model". Without it every circuit of the provider is cleared, as before. Either way the breaker logs one circuit-breaker: manual reset line per circuit cleared, with cause=manual reset, previous_state and the model, so a fleet-wide clean-up reads in the app log as N circuits with N causes.
This endpoint backs the circular-arrow button ("Reset circuit breaker") beside each open or half-open member on the Failover page.
Response (400): invalid provider UUID. Response (503): the circuit breaker is not available.
Clears every circuit behind one failover group's entries on this member, in one call instead of one per provider. Only the group's (provider, resolved model) pairs are touched: a provider's circuits for models outside the group keep their state.
Response:
{
"group_id": "uuid",
"display_model": "glm53",
"entries": 3,
"cleared": 2,
"recovered": 1
}| Field | Type | Meaning |
|---|---|---|
entries |
int | The group's entries that resolved to a model |
cleared |
int | Circuits that existed and were discarded, healthy ones included |
recovered |
int | Those that were sidelining their entry (open or half-open) |
Like the other resets it is outside the managed-write guard: a circuit is local runtime health, not synced config. Front Desk fans this out to every member (POST /api/fleet/circuit-breaker/reset, see High Availability).
Response (404): unknown group. Response (503): the circuit breaker is not available.
Clears every tracked circuit at once, for recovering from a fleet-wide upstream incident without resetting providers one by one.
Response:
{
"cleared": 7,
"recovered": 2
}| Field | Type | Meaning |
|---|---|---|
cleared |
int | Every circuit discarded, including healthy closed ones |
recovered |
int | Only those that were actually sidelining their provider (open or half-open), so a bulk reset does not imply every tracked provider was broken |
This endpoint is deliberately API only: there is no UI control for it, by decision rather than omission. Per-provider reset is the right operation almost every time and it names the provider being recovered, whereas a fleet-wide button placed beside it is too easy to reach for when one circuit is the problem, and it throws away the breaker's evidence about every other provider in the process. Bulk reset is left to scripts and runbooks, where its blast radius is written down.
Response (503): the circuit breaker is not available.
Response: 204 No Content
| Endpoint | Method | Description |
|---|---|---|
/api/virtual-keys |
GET | List virtual keys (key values are not returned) |
/api/virtual-keys |
POST | Create a virtual key (returns full key once) |
/api/virtual-keys/{id} |
GET | Get virtual key details (without key value) |
/api/virtual-keys/{id} |
PUT | Update virtual key (name, rate limits) |
/api/virtual-keys/{id} |
DELETE | Revoke a virtual key |
Response:
[
{
"id": "uuid",
"name": "Production Key",
"key_preview": "sk-...c5d6",
"tokens_used": 123456,
"last_used_at": "2024-01-01T00:00:00Z",
"created_at": "2024-01-01T00:00:00Z",
"rate_limit_rps": null,
"rate_limit_burst": null,
"rate_limit_tpm": null,
"allowed_providers": null,
"strip_reasoning": false,
"owner_user_id": "uuid",
"owner_username": "maya",
"budget_usd": null,
"budget_period": null
}
]owner_user_id is the account that owns the key (null for an unowned key) and owner_username its name, omitted when there is none. A non-admin caller sees and edits only its own keys, and a key it creates is always its own: owner_user_id in the body is honoured for admins only.

Request Body:
{
"name": "Production Key",
"rate_limit_rps": 10.0,
"rate_limit_burst": 20,
"rate_limit_tpm": 50000,
"allowed_providers": ["provider-uuid-1", "provider-uuid-2"],
"strip_reasoning": false
}| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | 1-100 characters, cannot be reserved names (chat, arena, completions, admin) |
rate_limit_rps |
number | No | Requests per second, 0 to 10000 (null = use global default) |
rate_limit_burst |
integer | No | Burst capacity, 1 to 10000 (null = use global default) |
rate_limit_tpm |
integer | No | Tokens-per-minute cap, 1 to 100000000 (null = no cap / global default). Counts prompt + completion (reasoning is part of completion); over-budget keys get 429 token rate limit exceeded with Retry-After
|
budget_usd |
number | No | Dollar budget per calendar period, above 0 and at most 10000000; null = no budget. Must be sent together with budget_period. Once the period's priced spend reaches it, requests get 429 key budget exceeded with Retry-After set to the period's end. On update, omitting both fields preserves the stored budget; sending both as null clears it |
budget_period |
string | No |
day, week (Monday to Sunday) or month, all UTC; null when budget_usd is null |
allowed_providers |
array of UUID strings | No | Restrict this key to the listed provider IDs (null = all providers accessible; an empty array is rejected) |
strip_reasoning |
boolean | No | Strip reasoning/reasoning_content fields from streaming output for this key |
owner_user_id |
UUID string | No | Admins only: the account that owns the key (null or absent = unowned on create; on update, null unassigns and omitting it keeps the current owner). Ignored for a non-admin caller, whose keys are always its own |
Response: 201 Created
{
"id": "uuid",
"name": "Production Key",
"key": "sk-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"key_preview": "sk-...c5d6",
"tokens_used": 0,
"last_used_at": null,
"created_at": "2024-01-01T00:00:00Z",
"rate_limit_rps": 10.0,
"rate_limit_burst": 20,
"rate_limit_tpm": 50000,
"budget_usd": 25,
"budget_period": "month",
"budget_spent_usd": 0
}budget_spent_usd is the key's priced spend in the current period on the answering member; it is absent when the key has no budget, and also while the period's spend could not be summed yet (the proxy then refuses the key's requests with 503 until the store answers). User accounts (/api/users) take and return the same budget_usd + budget_period pair.
⚠️ Important: The full key is shown only once at creation time and cannot be retrieved later.
Request Body: Same as POST, name is required, other fields optional
Response: 204 No Content
| Endpoint | Method | Description |
|---|---|---|
/api/logs |
GET | Query request logs (with pagination, filtering, sorting) |
/api/logs/cursor |
GET | Cursor-paginated request log listing |
/api/logs/{id} |
GET | Get a single request log entry by ID |
/api/logs/purge |
DELETE | Purge logs older than a specified period |
Caching: responses are cached in-process, keyed by the raw query string. Each response carries an
X-Cache: HITorX-Cache: MISSheader.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer | 1 | Page number |
per_page |
integer | 20 | Page size (max 200) |
model_id |
string | - | Filter by model ID (partial match) |
provider_id |
UUID | - | Filter by provider UUID |
virtual_key_id |
UUID | - | Filter by the virtual key that made the request |
client_ip |
string | - | Filter by the resolved client address |
owner_user_id |
UUID | - | Filter by the account that owns the key (admins only; a non-admin caller is scoped to their own rows regardless) |
status_code |
string | - | Filter by status code (4xx, 5xx, or exact integer; 0 = no response) |
endpoint_type |
string | - | Filter by endpoint family: chat, messages, responses, embeddings, rerank, image, tts, stt (unknown values are ignored) |
from |
RFC3339 | - | Start timestamp |
to |
RFC3339 | - | End timestamp |
attempt_provider_id |
UUID | - | Select requests whose per-attempt trail names this provider on ANY attempt, whoever served the request in the end ("every request in which Neuralwatt answered") |
attempt_status |
positive integer | - | Select requests with an attempt that reached this upstream status (0, "no response seen", cannot be selected: such attempts carry no status). Combined with attempt_provider_id, both must hold on the same attempt ("every request in which Neuralwatt returned 429") |
sort_by |
string | time |
Sort column: time, model, provider, status, tokens, tps, cost, ttft, response_header_ms, duration, overhead, key, ip. Anything else falls back to time. cost puts unpriced rows last |
sort_dir |
string | desc |
Sort direction: asc or desc
|
Response:
{
"entries": [
{
"id": "uuid",
"provider_id": "uuid",
"provider_name": "OpenAI",
"model_id": "gpt-4o",
"request_hash": "...",
"status_code": 200,
"latency_ms": 234.5,
"duration_ms": 456.7,
"ttft_ms": 123.4,
"response_header_ms": 98.7,
"proxy_overhead_ms": 1.2,
"parse_ms": 0.5,
"failover_lookup_ms": 0.1,
"model_lookup_ms": 0.3,
"provider_lookup_ms": 0.2,
"key_decrypt_ms": 0.8,
"dial_ms": 0.1,
"settings_read_ms": 0.1,
"cache_hits": {"failover": true, "model": true, "provider": true, "key": true, "settings": true},
"tokens_per_second": 45.6,
"tokens_prompt": 100,
"tokens_completion": 200,
"tokens_completion_reasoning": 0,
"tokens_prompt_cache_hit": 0,
"tokens_prompt_cache_miss": 0,
"search_units": 0,
"cost_usd": 0.0039,
"streaming": true,
"virtual_key_name": "Production Key",
"virtual_key_deleted": false,
"virtual_key_id": "uuid",
"client_ip": "203.0.113.7",
"error_message": "",
"error_kind": "",
"failover_attempt": 1,
"state": "completed",
"endpoint_type": "chat",
"created_at": "2024-01-01T00:00:00Z",
"resolved_model_id": "gpt-4o-2024-08-06",
"attempts": [
{"attempt": 0, "provider_id": "uuid", "provider": "Neuralwatt", "model": "glm-5.3", "status": 429, "error_kind": "provider_saturated", "detail": "concurrent_budget_exceeded", "phrase": "concurrent_budget_exceeded", "duration_ms": 412, "breaker": "noop"},
{"attempt": 1, "provider_id": "uuid", "provider": "OpenAI", "model": "gpt-4o", "status": 200, "duration_ms": 8299, "ttft_ms": 123.4, "breaker": "success"}
]
}
],
"total": 1000,
"page": 1,
"per_page": 20
}search_units is what a rerank provider billed (0 on every other row), client_ip the resolved client address (empty on older rows and address-less paths), resolved_model_id the upstream model id that actually served the request (for a hotel/ request model_id holds the group name). cost_usd is null when the request could not be priced.
attempts is the per-attempt trail: one element per failover attempt, in order, hedged probes (a hedged attempt abandoned in flight appears with no status and the exit as its error_kind: hedge_superseded when another candidate won, failover_timeout, client_disconnect) and in-flight busy skips included; sorted by attempt, skips first. attempt is the loop's index (the same numbering as failover_attempt); -1 marks a candidate the circuit breaker refused before any request was made (breaker: "skipped"). status is the upstream status the attempt reached (omitted when no response was seen), detail at most 160 characters of the sanitized, credential-masked upstream error (never request content), phrase the rate-limit phrase-table entry a 429 matched, and breaker what the attempt did to the circuit: charge, noop, success, alive, skipped or disabled. The field is omitted for rows without a trail (rows from before it existed, rows an older member wrote, requests that never reached a candidate). The terminal attempt's values also stay in the flat columns, so nothing that reads them changes. See Failover: the per-attempt trail.

Request Body:
{ "older_than": "1h" }Accepted values: 1h, 1d, 1w, 1m, all
Response: 204 No Content
| Endpoint | Method | Description |
|---|---|---|
/api/logs/app |
GET | Query application logs |
/api/logs/app/cursor |
GET | Cursor-paginated app log history |
/api/logs/app |
DELETE | Clear app logs (ring buffer + DB), all or older than a period |
Ring buffer mode (default):
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 500 | Max entries (max 1000) |
after |
RFC3339 | - | Only return entries after this timestamp |
Response: Array of log entries
[
{
"timestamp": "2024-01-01T00:00:00.000000000Z",
"level": "info",
"source": "proxy",
"message": "Request completed successfully"
}
]History mode (?history=true):
| Parameter | Type | Default | Description |
|---|---|---|---|
level |
string | - | Filter: info, warning, error
|
source |
string | - | Filter: proxy, auth, discovery, etc. |
search |
string | - | Text search in message (case-insensitive) |
from |
RFC3339 | - | Start timestamp |
to |
RFC3339 | - | End timestamp |
page |
integer | 1 | Page number |
per_page |
integer | 20 | Page size (max 100) |
sort_by |
string | time |
Sort: time, level, source, message
|
sort_dir |
string | desc |
Sort direction |
Response:
{
"entries": [...],
"total": 500,
"page": 1,
"per_page": 20,
"level_counts": {
"info": 450,
"warning": 30,
"error": 20
},
"source_counts": {
"proxy": 300,
"auth": 100,
"discovery": 100
}
}Request Body (optional):
{ "older_than": "1d" }Accepted values: 1h, 1d, 1w, 1m, all. No body (or no older_than) means all; a body that cannot be read, or another value, is a 400.
Response:
{ "deleted": 1234 }deleted is the number of database rows removed. Response (503): the app log writer did not drain its queue in time, so the purge was refused rather than undone by the pending flush; retry it.
| Endpoint | Method | Description |
|---|---|---|
/api/audit |
GET | Query the audit trail (cursor-paginated, newest first) |
/api/audit/purge |
DELETE | Purge audit entries older than a specified period |
Admin-only. Both endpoints require an admin session. The trail itself is written by middleware that records every mutating (POST/PUT/PATCH/DELETE) request on the authenticated
/api/*surface from any signed-in account, admin or user. Request and response bodies are never stored.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 50 | Page size (max 200) |
cursor |
string | - | Keyset cursor from a previous response's next_cursor
|
offset |
integer | 0 | Rows to skip, for offset paging instead of the cursor |
actor |
string | - | Filter by actor (exact match: a username, or admin for legacy admin-token/passkey/SSO logins) |
method |
string | - | Filter by HTTP method (POST, PUT, PATCH, DELETE) |
from |
RFC3339 | - | Start timestamp |
to |
RFC3339 | - | End timestamp |
Response:
{
"entries": [
{
"id": "uuid",
"created_at": "2026-01-01T00:00:00Z",
"actor": "maya",
"actor_role": "user",
"method": "PUT",
"route": "/api/virtual-keys/{id}",
"path": "/api/virtual-keys/0a1b2c3d-...",
"entity_id": "0a1b2c3d-...",
"entity_name": "maya-cli",
"status_code": 200,
"remote_addr": "10.0.0.17:41822"
}
],
"total": 132,
"has_more": true,
"next_cursor": "..."
}entity_name is resolved at read time from the entity's current display name (models, providers, virtual keys, failover groups, users) and is omitted when the entity has been deleted or the route has no mapped entity; the UUID in entity_id then remains the only trace. Names are never stored, so a rename shows the current name.
Request Body:
{ "older_than": "1w" }Accepted values: 1h, 1d, 1w, 1m, all
Response: 204 No Content
The purge is itself a mutating request and is recorded by the audit middleware, so a wiped trail always shows who wiped it.
| Endpoint | Method | Description |
|---|---|---|
/api/backups |
GET | List available backups |
/api/backups |
POST | Create a new backup |
/api/backups/restore |
POST | Restore the database from an uploaded backup file |
/api/backups/{filename} |
GET | Download a backup file |
/api/backups/{filename}/signature |
GET | Fetch a backup's signature sidecar (for the restore form) |
/api/backups/{filename} |
DELETE | Delete a backup |
/api/backups/prune-preview |
POST | Preview which backups would be pruned (dry run) |
/api/backups/prune |
POST | Execute backup rotation and prune old backups |
Response:
[
{
"filename": "backup_20240101_120000_123456.dump",
"size_bytes": 10485760,
"created_at": "2024-01-01T12:00:00Z",
"origin": "manual",
"signed": true
}
]origin is manual (created from the API or dashboard), scheduled (the rotation scheduler) or frontdesk (requested with ?origin=frontdesk); only scheduled backups are rotated. signed says a signature sidecar exists, not that it verifies: the check runs on download.

Creates a PostgreSQL backup using pg_dump (custom format, zstd-compressed: level 12 here, so the request stays short, and level 19 for scheduled backups; readable by pg_restore 16 or later built with zstd, which the postgres:16-alpine image is).
Response: 201 Created
{
"filename": "backup_20240101_120000_123456.dump",
"size_bytes": 10485760,
"created_at": "2024-01-01T12:00:00Z",
"origin": "manual",
"signed": true
}Error Responses:
-
409 Conflict- Backup already in progress -
412 Precondition Failed-pg_dumpnot found (installpostgresql-client) -
500 Internal Server Error- Backup failed
Downloads the backup file.
Response: File download with Content-Disposition: attachment
Error Responses:
-
403 Forbidden- Read-only demo mode -
404 Not Found- Backup does not exist -
409 Conflict- A signed backup failed its integrity check (contents changed after signing); nothing is served -
500 Internal Server Error- The file or its signature could not be read
Replaces the database with an uploaded dump. multipart/form-data, at most 100 MB:
| Field | Required | Description |
|---|---|---|
dump |
Yes | The .dump file, under its original filename (the signature covers the name) |
admin_token |
Yes | The admin token re-typed as a step-up; a session token never counts. With TOTP enabled it is accepted only on a request that is itself an admin session |
signature |
No | The backup's signature (from GET /api/backups/{filename}/signature). A mismatch is refused; an absent one is allowed and raises backup.restore_unverified
|
Before restoring, the dump is checked with pg_restore --list: it must be a Model Hotel backup (carry schema_migrations), hold no dangerous objects, and not come from a newer version. The restore runs in a single transaction; on success the process exits and the container restart re-runs any missing migrations.
Response:
{ "migration_count": 91, "known_count": 92 }migration_count is the migrations recorded in the dump, known_count those this build knows.
Error Responses:
-
400 Bad Request- Unreadable form, missingdump, signature mismatch, invalid dump, dangerous objects, noschema_migrations, or a dump from a newer version -
401 Unauthorized- Missing or wrongadmin_token -
409 Conflict- Another backup or restore is in progress -
412 Precondition Failed-pg_restorenot found -
500 Internal Server Error- Saving, verifying or restoring the dump failed
Returns the backup's HMAC signature sidecar, the value the restore endpoint takes in its signature form field. The download serves the dump alone, so this is how an operator without shell access to the backup directory carries the signature to a restore. Not verified here: the restore checks it against the uploaded bytes.
Response:
{ "signature": "3f1a…64 hex characters…" }Error Responses:
-
400 Bad Request- Invalid filename -
404 Not Found- Backup does not exist, or has no signature (backups predating signing, dumps copied in from another instance, or a backup whose signing failed at creation; see thebackup.unsignedevent) -
500 Internal Server Error- The sidecar exists but cannot be read, or is not a valid signature
Response: 204 No Content
Error Responses:
-
400 Bad Request- Invalid filename -
404 Not Found- Backup does not exist -
409 Conflict- Another backup operation (a dump, restore, prune or delete) is in progress -
500 Internal Server Error- The file could not be removed
Preview which backups would be pruned under the son/father/grandfather rotation scheme (three retention tiers: daily "son" backups, weekly "father", monthly "grandfather", each with its own count). Non-destructive (dry run).
Response:
{
"son": [
{"filename": "backup_20260608_120000_0000.dump", "size_bytes": 1048576, "created_at": "2026-06-08T12:00:00Z"}
],
"father": [],
"grandfather": [],
"prune": [
{"filename": "backup_20260301_120000_0000.dump", "size_bytes": 2097152, "created_at": "2026-03-01T12:00:00Z"}
]
}Execute the son/father/grandfather rotation, deleting backups that fall outside the retention tiers. Returns the same classification as prune-preview.
Response: Same structure as prune-preview, reflecting the state after pruning.
| Endpoint | Method | Description |
|---|---|---|
/api/settings |
GET | List all settings (key-value map) |
/api/settings |
PUT | Update settings (partial update) |
/api/settings |
DELETE | Reset settings to Go-side defaults. Body: {"keys": ["key1", ...]}; empty keys array resets all. Returns the full updated settings map. |
Every value is a string, whatever its logical type. The map also carries a few read-only keys that PUT rejects: app_version, app_commit, log_export_json, log_export_metrics, log_export_otel and discovery_claim_window_days. Secret values (alert_apprise_targets, oidc_client_secret, github_client_secret) come back as a fixed placeholder, never as stored ciphertext or plaintext.
Response:
{
"rate_limit_enabled": "true",
"rate_limit_ip_rps": "30",
"rate_limit_ip_burst": "60",
"discovery_interval": "6h",
"discovery_on_startup": "true",
"circuit_breaker_enabled": "true",
"app_version": "1.2.3"
}
Request Body:
{
"rate_limit_ip_rps": "50",
"discovery_interval": "12h"
}A key outside the allowlist below is a 400 (unknown setting: <key>), as is a value longer than 500 characters or outside its range. A managed fleet member additionally answers 403 for any synced key: the primary owns those and replaces them on the next sync.
Allowed Settings:
| Key | Type | Constraints and meaning |
|---|---|---|
rate_limit_enabled |
string |
"true" or "false"
|
rate_limit_ip_enabled |
string |
"true" or "false"
|
rate_limit_ip_rps |
float | 0-10000 |
rate_limit_ip_burst |
int | 1-10000 |
rate_limit_max_wait_ms |
int | 0-10000 |
rate_limit_rps |
float | 0-10000 |
rate_limit_burst |
int | 1-10000 |
rate_limit_tpm |
int | 0-100000000; global per-key tokens-per-minute default, 0 = no cap |
request_timeout |
string | Duration (e.g. "1m0s") |
failover_on_rate_limit |
string |
"true" or "false"
|
failover_exhaustion_status_429 |
string |
"true" or "false"; all-busy/all-pinned exhaustion answers 429 plus Retry-After instead of 502
|
server_error_retry_enabled |
string |
"true" or "false"; the last candidate's one same-provider retry of a transient 5xx |
rate_limit_classify_enabled |
string |
"true" or "false"; master switch for saturation-vs-exhaustion classification of a 429
|
rate_limit_saturation_max_wait |
string | Duration; a Retry-After at or below this reads as saturation, and it caps the saturation wait |
rate_limit_recent_success_window |
string | Duration; an unclassifiable 429 following a 2xx this recent is treated as saturation |
inflight_limiter_enabled |
string |
"true" or "false"; adaptive per-provider concurrency learner |
inflight_grow_after |
int | 1-1000; clean completions per +1 of a capped in-flight window |
inflight_forget_after |
string | Duration; a capped window returns to uncapped after this long without a cut |
circuit_breaker_enabled |
string |
"true" or "false"
|
circuit_breaker_open_on_exhaustion |
string |
"true" or "false"; one exhausted 429 opens the model circuit outright |
circuit_breaker_threshold |
int | 1-100 |
circuit_breaker_span_models |
int | 1-100 (default 2); open model circuits it takes to skip the provider itself |
circuit_breaker_cooldown |
string | Duration |
circuit_breaker_quota_pin_max |
string | Duration ceiling for pinning an open circuit's cooldown to the provider's quota reset (default "24h0m0s"); "0s" switches pinning off |
circuit_breaker_pin_probe_interval |
string | Duration between probes of a circuit pinned on a response's own claim (default "1h0m0s"); "0s" disables the probe |
circuit_breaker_backoff_max |
string | Duration ceiling for doubling an open circuit's cooldown per failed half-open probe (default "15m0s"); "0s" switches backoff off |
discovery_interval |
string | Duration (e.g. "6h", "0" = disabled) |
discovery_on_startup |
string |
"true" or "false"
|
discovery_on_provider_create |
string |
"true" or "false"
|
discovery_claim_alert_days |
int | 1-29; age at which an unaddressed discovery claim raises an alert. The ceiling is one day below the 30-day claim window |
model_prune_days |
int | 0-180; days an unlisted model stays before its row is deleted, 0 = never |
log_retention |
string | Any Go duration ("24h", "48h", "168h0m0s"); legacy "1d"/"1w"/"1m" (30 days) still accepted; "0" or empty = keep forever |
stale_request_timeout |
string | Duration |
key_cache_ttl |
string | Duration (e.g. "10m0s") |
ttft_timeout |
string | Duration; time-to-first-token probe timeout for streaming ("0s" disables) |
stream_stall_timeout |
string | Duration; max silence during streaming before termination ("0s" disables) |
upstream_header_timeout |
string | Duration; max wait for a provider's response headers ("0s" lifts the limit) |
hedging_enabled |
string |
"true" or "false"
|
hedge_delay |
string | Duration before a backup provider is raced (default "4s") |
backup_enabled |
string |
"true" or "false" (periodic backup with rotation) |
backup_interval |
string | Duration between automatic backups (default "24h"); the scheduler runs anything under 5 minutes at 5 minutes and anything over 168 hours at 168 hours |
backup_son_retention |
int | 1-365 (daily tier) |
backup_father_retention |
int | 0-52 (weekly tier) |
backup_grandfather_retention |
int | 0-120 (monthly tier) |
alert_enabled |
string |
"true" or "false"; outbound alerting through apprise-api |
alert_apprise_api_url |
string | Base URL of the apprise-api container, validated against SSRF targets |
alert_apprise_targets |
string | Secret: encrypted at rest, masked on read |
alert_events |
string | Comma-separated list of the event types to notify on |
session_idle_timeout_minutes |
int | 0-240; dashboard auto-logout window, 0 = disabled |
pwned_password_check_enabled |
string |
"true" or "false"; breached-password screening |
oidc_enabled |
string |
"true" or "false"
|
oidc_issuer_url |
string | OIDC discovery base URL, validated against SSRF targets |
oidc_client_id |
string | OAuth client id |
oidc_client_secret |
string | Secret: encrypted at rest, masked on read |
oidc_allowed_emails |
string | Comma- or newline-separated allowlist |
oidc_public_base_url |
string | This app's external origin, used to build the redirect URI |
github_sso_enabled |
string |
"true" or "false"
|
github_client_id |
string | GitHub OAuth App client id |
github_client_secret |
string | Secret: encrypted at rest, masked on read |
github_allowed_emails |
string | Comma- or newline-separated allowlist of verified emails |
github_public_base_url |
string | This app's external origin, used to build the callback URI |
quota_refresh_interval_min |
int | 0-30; provider quota poll interval in minutes, 0 = disabled |
Response: 200 OK with full settings map
| Endpoint | Method | Description |
|---|---|---|
/api/stats |
GET | Dashboard statistics |
/api/stats/timeseries |
GET | Time-series data for charts |
/api/stats/provider-distribution |
GET | Top 5 provider distribution |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
period |
string | 24h |
Time period: 1h, 24h, 7d (anything else is read as 24h) |
exclude_deleted |
boolean | false |
Set true to exclude rows whose virtual key has been deleted |
metric |
string | requests |
Metric for aggregation: requests, tokens or cost (US dollars from cost_usd; see Request Logging) |
include_latency |
boolean | false |
Set true to add the latency breakdown to the response |
Response:
{
"total_requests_last_24h": 12345,
"total_requests_last_7d": 54321,
"by_model": {
"OpenAI/gpt-4o": 5000,
"Anthropic/claude-3-opus": 2000,
"hotel/fast-chat": 1000
},
"by_provider": {
"OpenAI": 8000,
"Anthropic": 4000
},
"by_virtual_key": {
"Production Key": 10000,
"Development": 2000
},
"avg_latency_ms": 234.5,
"error_rate": 0.02,
"avg_overhead_ms": 1.2,
"total_tokens_prompt": 500000,
"total_tokens_completion": 750000,
"total_tokens_cache_hit": 120000,
"total_cost_usd": 12.34,
"requests_unpriced": 3,
"avg_tokens_per_request": 101.2,
"rate_limit_hits": 15,
"avg_ttft_ms": 123.4,
"requests_last_1h": 500,
"by_provider_latency": [
{"provider_name": "OpenAI", "total_ms": 234.5, "overhead_ms": 1.2, "provider_ms": 233.3, "request_count": 8000}
]
}total_tokens_cache_hit is the prompt tokens served from the provider's cache. by_provider_latency is filled only with include_latency=true (the six providers with the highest average total latency) and is null otherwise.
total_cost_usd sums request_logs.cost_usd over the period. Requests the proxy could not price add nothing, and requests_unpriced counts the served (2xx) ones among them, so the total is a floor by that many requests. The by_* maps carry whichever metric was asked for, so under metric=cost their values are dollars.
Query Parameters: Same as /api/stats
Response:
{
"points": [
{
"bucket": "2024-01-01T00:00:00Z",
"count": 100,
"tokens": 50000,
"tokens_cache_hit": 12000,
"tokens_cache_miss": 8000,
"cost_usd": 0.42,
"errors": 2,
"latency_ms": 234.5,
"overhead_ms": 1.2,
"provider_latency_ms": 230.0,
"rate_limit_hits": 1,
"avg_ttft_ms": 123.4
}
]
}Returns hourly buckets for 1h and 24h periods, daily buckets for 7d. Empty buckets are filled with zeros.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
period |
string | 24h |
Time period: 1h, 24h, 7d (anything else is read as 24h) |
exclude_deleted |
boolean | false |
Set true to exclude rows whose virtual key has been deleted |
metric |
string | requests |
Distribution metric: requests, tokens or cost. Providers with nothing to show under the metric (no tokens, or only unpriced or free requests) are left out |
Response:
{
"items": [
{
"name": "OpenAI",
"count": 8000,
"tokens": 0,
"cost_usd": 0,
"share": 66.7
},
{
"name": "Anthropic",
"count": 4000,
"tokens": 0,
"cost_usd": 0,
"share": 33.3
}
]
}| Endpoint | Method | Description |
|---|---|---|
/api/system |
GET | System status (CPU, memory, Go runtime, DB, Docker) |
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
since |
RFC3339 | Start of day for requests_today calculation (defaults to UTC midnight) |
Response:
{
"app": {
"heap_alloc_mb": 45.6,
"sys_memory_mb": 128.0,
"goroutines": 50,
"gc_cycles": 123,
"memory_current_bytes": 134217728,
"memory_limit_bytes": 1073741824,
"in_container": true,
"uptime_seconds": 86400,
"cpu_percent": 2.5,
"requests_today": 12345,
"net_rx_bytes_sec": 1024.0,
"net_tx_bytes_sec": 2048.0,
"disk_read_bytes_sec": 512.0,
"disk_write_bytes_sec": 256.0,
"procs": 4
},
"db": {
"size_mb": 256.0,
"connections": 10,
"cache_hit_ratio": 99.5,
"cache_window_blocks": 48213,
"tx_per_sec": 15.3,
"dead_tuples": 1000,
"lock_waits": 0
},
"docker": {
"available": true,
"cpu_percent": 5.0,
"memory_usage_bytes": 536870912,
"memory_limit_bytes": 2147483648,
"net_rx_bytes_sec": 1024.0,
"net_tx_bytes_sec": 2048.0,
"disk_read_bytes_sec": 512.0,
"disk_write_bytes_sec": 256.0,
"procs": 12,
"container_count": 3
},
"fleet": {
"state": "member",
"is_primary": false,
"primary_name": "mh1",
"frontdesk_id": "...",
"managed_seen_at": "2024-01-01T12:00:00Z",
"config_synced_at": "2024-01-01T11:55:00Z"
},
"instance_id": "uuid"
}docker aggregates every container of the Compose project, as rates per second; available is false when the Docker API cannot be read. db.cache_window_blocks is how many block accesses the window behind cache_hit_ratio held, omitted when the ratio rests on no fresh activity. fleet is this instance's HA membership (state is primary, member or warning), omitted for a standalone instance Front Desk has never contacted. instance_id is the instance's stable identity, which Front Desk uses to recognize one instance reached under two URLs.
| Endpoint | Method | Description |
|---|---|---|
/api/events |
GET | Server-Sent Events stream (any signed-in identity; events filtered per caller) |
Long-lived SSE stream for real-time dashboard updates. Any signed-in identity may connect (admin token or session, as on the rest of /api). Admins receive every event. A non-admin receives only request.* lifecycle events (not request.discovery.*), and only when it holds the logs grant and the event belongs to one of its own virtual keys; every other event type stays admin only. The credentials are re-checked on every heartbeat, so a changed grant applies mid-stream and a revoked session or disabled account closes the stream after two failed checks.
Response Headers:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
Event Format:
: connected
data: {"type":"discovery.complete","severity":"success","message":"Discovery complete: 15 models across 2 providers","metadata":{"source":"Startup","models_pruned":0}}
: heartbeat
Event Types:
| Event | Severity | Description |
|---|---|---|
discovery.complete |
success/warning/error
|
A discovery run finished |
discovery.provider_fetched |
success |
Fetched models from a provider |
discovery.provider_failed |
error |
Discovery failed for a provider |
discovery.enriched |
info |
Models enriched from the models.dev catalogue |
discovery.models_disabled |
warning |
Models were disabled during discovery |
discovery.changes_pending |
info |
Background discovery recorded model changes (badged on the Models nav) |
discovery.claims_outstanding |
warning |
Model discrepancies have gone unaddressed past discovery_claim_alert_days
|
discovery.suspect_scan |
warning |
A scan produced models that look wrong and were held back from auto-disable |
discovery.bulk_removal_suspected |
error |
A provider dropped so many models at once that the listing itself is suspect |
model.auto_disabled_gone |
warning |
A model was disabled because the provider kept refusing it as retired |
provider.scheduled_disable |
warning |
A provider reached its scheduled_disable_on date and was switched off |
failover.sync_error |
warning |
Error during failover group synchronization |
circuit_breaker.open |
warning |
A circuit opened |
circuit_breaker.closed |
success |
A circuit closed (recovered) |
circuit_breaker.unstable |
warning |
One model opened its circuit 3 times in 24h, so it keeps returning to service still broken |
quota.schema_drift |
warning |
A provider changed the shape of its quota response |
quota.status_lost |
warning |
A provider's quota reading stopped: the stored snapshot went from 200 to anything else (204 no plan, 424 key rejected). A move between two non-200 answers is not announced. Metadata: provider_id, provider, provider_type, kind, http_status, previous_http_status
|
quota.status_restored |
success |
The reading is back (200 again); same metadata |
quota.status_synced |
success / warning
|
The same flip on a fleet member for a provider the primary is feeding: it arrived in the primary's pushed snapshot, or the member's own fetch (a breaker nudge polls on every member at once) replaced a pushed row the primary fetched less than one quota_refresh_interval_min ago. Dashboard only (badges refetch on every quota.status_* type); not alertable, the primary alerts within the interval. Any other flip a member finds (its pushed row already stale, so the primary is not feeding that provider), a push that flips a row the member fetched itself, and every flip on a standalone install publish the alertable pair |
budget.warning |
warning |
A virtual key or user reached 80% of its dollar budget for the period |
budget.exceeded |
error |
A virtual key or user reached its dollar budget; its requests are refused until the period ends |
tokens.error |
error |
Error counting tokens |
request.started |
info |
A proxied request started |
request.streaming |
info |
A proxied request began streaming |
request.completed |
varies | A proxied request finished (severity follows the outcome) |
request.discovery.provider_starting |
info |
Starting discovery for a provider |
request.discovery.provider_completed |
info |
Discovery finished for a provider |
backup.created |
success |
Database backup created (manual or scheduled) |
backup.deleted |
info |
Backup deleted |
backup.pruned |
info |
Backup pruned by rotation |
backup.restored |
success |
Database restored from a backup |
backup.integrity_failed |
error |
A backup failed its integrity check |
backup.restore_unverified |
warning |
A restore ran from a backup whose signature could not be verified |
backup.unsigned |
warning |
A backup was written without a signature |
configsync.malformed_password_hash |
error |
A config export or import carried an unusable password hash |
fleet.conflict |
warning |
Two fleet members claim the same role |
auth.sessions_revoked |
info |
Sessions were signed out |
auth.sso_identity_bound |
warning |
An SSO identity was bound to an existing account |
webauthn.credential_registered |
success |
A passkey was registered |
webauthn.credential_deleted |
info |
A passkey was deleted |
logs.stale_startup |
warning |
Requests left in flight by a previous process were closed out at startup |
logs.stale_cleanup |
warning |
The background sweep closed out requests that never finished |
Front Desk publishes its own event set (member.*, fleet.*, config.*, health.*, settings.changed, and more) on its own stream; those never appear here.
Heartbeat comments (: heartbeat) are sent every 30 seconds.
Circuit breaker event metadata:
circuit_breaker.open and circuit_breaker.closed carry the same metadata block:
| Field | Type | Present | Meaning |
|---|---|---|---|
provider_id |
string (UUID) | always | The provider whose circuit changed state |
provider |
string | always | Provider name, so the event reads without a lookup |
model |
string | always | The resolved upstream model id whose circuit changed state (never a hotel/ alias) |
model_id |
string | on closed always; on open unless provider_open is true
|
The same value as model, carried separately because outbound alerts debounce on it |
state |
string | always |
open or closed
|
cause |
string | always | A fixed phrase chosen by the gateway for the transition (upstream status 503, success, ...), never provider text; see Why a circuit is open
|
status |
int | always | The upstream HTTP status behind cause; 0 when no response was seen |
provider_open |
bool | always | Whether the provider as a whole is now being skipped |
consecutive_fails |
int | always | Consecutive failures recorded against that model's circuit |
quota_pinned |
bool | always | Whether a quota reset deadline is in force on this circuit |
pin_source |
string | always | Where a pin came from: advisor (measured by the quota poller off a spent window), reserve (measured off the operator's quota reserve line, with the reserve still left), response (inferred from the exhausted reply), account (the reply refused the whole account); empty when no pin governs |
backed_off |
bool | always | Whether the probe backoff is in force on this circuit |
failed_probes |
int | always | Half-open probes that failed since the circuit last closed |
cooldown_ms |
int | on open
|
The cooldown actually enforced |
next_retry_at |
string (RFC3339) | on open, when quota_pinned or backed_off is true
|
When the circuit is next eligible to probe |
next_retry_at is the retry deadline, not the provider's quota reset time: it is the open moment plus the cooldown actually in force, and the longer of a pin and a backoff governs. A pin can also be lifted early, without any event, when a quota refresh shows the provider back in credit. See Quota-pinned cooldowns.
circuit_breaker.unstable is not a state transition and carries a smaller block: provider_id, provider, model, model_id, opens (always 3) and window (the string 24h). It fires at most once per model per window and never disables anything: a model the provider has stopped serving is retired by the model-gone path instead.
Quota schema drift metadata:
| Field | Type | Meaning |
|---|---|---|
provider_id |
string (UUID) | The provider that reshaped its quota response |
provider |
string | Provider name |
provider_type |
string | Provider family (openai, anthropic, …) |
kind |
string | Which quota document changed |
added |
array of string | Key paths present now but not in the stored baseline |
removed |
array of string | Key paths that were in the baseline and have gone |
quota.schema_drift is alert-only: it never affects routing, failover, or the circuit breaker. It exists because the failure it guards against is silent: a normalizer written against the old shape keeps answering, wrongly, and nothing else would ever say so.
Available only when WEBAUTHN_RP_ID is configured; without it none of these routes is mounted and each answers 404, /api/webauthn/available included (see Security for the full authentication flow).
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/webauthn/available |
GET | None (public) | Check if WebAuthn is enabled ({"enabled": true, "has_credentials": true}; the login screen offers passkeys only when both are true) |
/api/webauthn/login/start |
POST | IP rate-limited | Begin passkey login |
/api/webauthn/login/finish |
POST | IP rate-limited | Complete passkey login, receive session token |
/api/webauthn/register/start |
POST | Admin/session token | Begin credential registration |
/api/webauthn/register/finish |
POST | Admin/session token | Complete credential registration |
/api/webauthn/credentials |
GET | Admin/session token | List registered credentials |
/api/webauthn/credentials/{id} |
PATCH | Admin/session token | Rename a credential |
/api/webauthn/credentials/{id} |
DELETE | Admin/session token | Delete a credential |
/api/webauthn/logout |
POST | Admin/session token | Revoke the current session token |
Time-based one-time passwords (RFC 6238) as an admin-login second factor, independent of passkeys. Opt-in at runtime from Settings; no environment variable required (see Security for the full authentication flow and enforcement model).
| Route | Method | Auth | Description |
|---|---|---|---|
/api/totp/status |
GET | None (public) | Report whether TOTP is enabled ({"enabled": true, "enabled_at": "<RFC3339>"}; enabled_at omitted while disabled) |
/api/totp/login |
POST | IP rate-limited; failures back off per IP and per account | Exchange admin token + 6-digit code (or a recovery code) for a session token. Body must be application/json (415 otherwise) |
/api/totp/info |
GET | Admin/session token | Enrollment state and remaining recovery-code count |
/api/totp/enroll/start |
POST | Admin/session token | Begin enrollment; returns the otpauth URI + base32 secret |
/api/totp/enroll/verify |
POST | Admin/session token | Verify the first code, enable TOTP, return recovery codes + a session token. 409 while TOTP is already on; wrong codes back off |
/api/totp/disable |
POST | Admin/session token | Disable TOTP (gated on a current code or recovery code; wrong codes back off) |
When TOTP is enabled, the raw admin token alone no longer authorizes /api/*: it is a first factor that must be exchanged via /api/totp/login for a session token.
| Endpoint | Method | Description |
|---|---|---|
/api/version/latest |
GET | Latest released version tag (fetched from GitHub, cached), used by the dashboard update notice |
| Endpoint | Method | Description |
|---|---|---|
/api/chat/chat |
POST | Interactive chat session (admin-authenticated, single model) |
/api/chat/arena |
POST | Arena mode (admin-authenticated, multi-model comparison) |
/api/chat/completions |
POST | Admin-authenticated chat completion (single model) |
These endpoints proxy through the same completion handler as /v1/chat/completions, but authenticate a dashboard session instead of a virtual key: any signed-in identity holding the chat grant may use them, not only an admin. Streaming and non-streaming both work as on /v1.
Four limiters apply in order: the per-IP limiter every /api route carries, the per-key RPS limiter (bucketed on the route name, chat/arena/completions, since there is no virtual key here, alongside the caller's per-user request cap), the caller's own per-user tokens-per-minute cap, and the caller's per-user dollar budget. A user with a TPM cap or a budget is metered here exactly as on /v1.
Request/Response: Same format as /v1/chat/completions
Routes that exist but have no section of their own. Everything under /api carries the same admin/session authentication unless the row says otherwise.
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/metrics |
Any (GET in practice) |
METRICS_TOKEN or admin token |
Prometheus metrics; never anonymous, and outside the per-IP limiter so scrapers are not throttled |
/api/public-config |
GET | None (public) | Feature flags the login screen needs (e.g. read-only demo mode) |
/api/demo-login |
GET | None (public) | Demo-mode login helper |
/api/auth/status |
GET | None (public) | Whether password login is available |
/api/auth/login |
POST | None (IP rate-limited) | Password login; mints a session. Body must be application/json (415 otherwise) |
/api/auth/admin-exchange |
POST | None (IP rate-limited) | Trade a raw admin token for an HttpOnly session cookie. Body must be application/json (415 otherwise): {"admin_token": "<token>"}
|
/api/auth/logout |
POST | Session | End the current session |
/api/auth/me |
GET | Any signed-in identity | The caller's identity, role and grants |
/api/auth/password |
POST | Any signed-in identity | Rotate the caller's own password |
/api/auth/sessions |
GET | Any signed-in identity | The caller's own active sessions |
/api/auth/sessions/{id} |
DELETE | Any signed-in identity | Revoke one of the caller's own sessions |
/api/auth/sessions/revoke-others |
POST | Any signed-in identity | Sign the caller's other sessions out |
/api/auth/totp/status, /enroll/start, /enroll/verify, /disable
|
GET/POST | Any signed-in identity | Per-user TOTP self-service (the /api/totp/* routes above are the admin-level equivalents) |
/api/auth/oidc/status, /start, /callback
|
GET | None (the ceremony is the login) | OIDC single sign-on |
/api/auth/github/status, /start, /callback
|
GET | None (the ceremony is the login) | GitHub single sign-on |
/api/users |
GET/POST | Admin | List and create accounts |
/api/users/grants |
GET | Admin | The catalogue of assignable grants |
/api/users/{id} |
PUT/DELETE | Admin | Update or delete an account |
/api/users/{id}/password |
POST | Admin | Set another account's password |
/api/users/{id}/totp/reset |
POST | Admin | Clear an account's TOTP enrollment |
/api/alert/events |
GET | Admin | The catalogue of alertable event types |
/api/alert/status |
GET | Admin | Whether outbound alerting is configured and reachable |
/api/alert/targets |
GET | Admin | The configured apprise targets |
/api/alert/probe |
POST | Admin | Check that apprise-api answers |
/api/alert/test |
POST | Admin | Send a test notification |
/api/config/export |
GET | Admin | Fleet config export (the primary's side of config sync) |
/api/config/version |
GET | Admin | The exporting member's config version, for the skew check |
/api/config/import |
POST | Admin | Apply an exported config on this member |
/api/config/quota-snapshots |
GET/POST | Admin | Export or receive fleet quota snapshots (no key material, so no MASTER_KEY canary) |
/api/fleet/announce |
POST | Admin | Front Desk's membership heartbeat |
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/health |
GET | None | Database reachability: 200 OK or 503 DEGRADED
|
Returns 200 with the body OK while the database answers, and 503 with the body DEGRADED when it does not, so a load balancer stops routing to an instance whose Postgres is down. The probe result is cached briefly, so a burst of health checks costs one database round-trip. Content type is text/plain; no authentication is required.
{
"error": {
"message": "error description",
"type": "invalid_request_error",
"code": 400
}
}code repeats the HTTP status as a number. type is derived from that status and is one of exactly seven values; messages are lowercase.
| Status | type |
|---|---|
401 |
authentication_error |
403 |
permission_error |
404 |
not_found_error |
429 |
rate_limit_error |
499 |
client_closed_request |
500 and above |
server_error |
| anything else | invalid_request_error |
| Code | Description | Common Causes |
|---|---|---|
200 |
OK | Successful request |
201 |
Created | Resource created successfully |
204 |
No Content | Successful deletion; a quota endpoint with no data for this key (e.g. a NeuralWatt free tier) |
400 |
Bad Request | Invalid request body, validation errors |
401 |
Unauthorized | Missing or invalid authentication |
403 |
Forbidden | Authenticated but not permitted (missing grant, read-only demo, managed fleet member) |
404 |
Not Found | Resource not found |
409 |
Conflict | Duplicate resource, operation in progress |
412 |
Precondition Failed | Missing dependency (e.g. pg_dump) |
415 |
Unsupported Media Type | A login route (/api/auth/login, /api/auth/admin-exchange, /api/totp/login) sent a body that is not application/json
|
424 |
Failed Dependency | A quota endpoint (/usage, /balance, /account) whose provider key is dead or revoked |
429 |
Too Many Requests | Rate limit exceeded |
499 |
Client Closed Request | The caller hung up before the answer (nginx's non-standard code, recorded in the request log) |
500 |
Internal Server Error | Server error; on the proxy, could not resolve model when the model lookup itself failed (the database, not an unknown model) |
502 |
Bad Gateway | Upstream provider error |
503 |
Service Unavailable |
/health with the database down; a budget that cannot be summed yet; the circuit breaker not available; an app log purge the writer could not drain in time |
504 |
Gateway Timeout | An /api request that outran its 60-second route timeout |
Invalid Virtual Key:
{
"error": {
"message": "invalid virtual key",
"type": "authentication_error",
"code": 401
}
}Rate Limit Exceeded:
{
"error": {
"message": "rate limit exceeded",
"type": "rate_limit_error",
"code": 429
}
}Model Not Found:
{
"error": {
"message": "model not found: hotel/gpt-5",
"type": "not_found_error",
"code": 404
}
}No Provider Left:
{
"error": {
"message": "virtual key does not have access to any provider for this model",
"type": "permission_error",
"code": 403
}
}| Route Group | Auth Method | Token Format |
|---|---|---|
/v1/* |
Virtual Key | Bearer sk-... |
/api/* |
Admin token or a session (password, passkey, TOTP, OIDC or GitHub login); non-admin accounts limited by grants |
Bearer <admin-token>, Bearer <session-token>, or session cookie plus X-CSRF-Token on unsafe methods |
/api/events |
Any signed-in identity (events filtered per caller) | as /api/*
|
/api/chat/* |
Any signed-in identity holding the chat grant |
Bearer <admin-token> or session cookie |
/api/webauthn/available, /api/webauthn/login/*
|
None (IP rate-limited) | - |
/api/totp/status |
None (public) | - |
/api/totp/login |
None (IP rate-limited; failures back off per IP and per account) | - |
/health |
None | - |
Virtual keys can have custom rate limits configured. If not set, global defaults apply.
Headers (when rate limited):
Retry-After: 60
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Burst: 20
Retry-After is only set when a wait was computed. X-RateLimit-Scope names the stage that refused: ip for the per-IP limiter and ratelimit-tpm for a tokens-per-minute cap (per key or per user); a per-key or per-user request-rate refusal carries none. A client can so tell an address-wide refusal from a key-wide one.
Applied to /api/*, /api/chat/* and /v1/* (not to /health, /metrics or the dashboard's static files), configurable via settings:
| Setting | Default | Description |
|---|---|---|
rate_limit_ip_rps |
30 | Requests per second per IP |
rate_limit_ip_burst |
60 | Burst capacity |
Trusted proxies (via TRUSTED_PROXIES env var) use the X-Forwarded-For header for IP identification.
CORS is configurable via the CORS_ORIGINS environment variable (comma-separated list).
Response Headers (when origin matches):
Access-Control-Allow-Origin: <origin>
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-CSRF-Token, x-api-key
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
A preflight that names its headers in Access-Control-Request-Headers gets that list echoed back as Access-Control-Allow-Headers instead, so browser SDKs can send their own (anthropic-version, x-stainless-*). Every request carrying an Origin gets Vary: Origin, Access-Control-Request-Headers.
Preflight: an OPTIONS request carrying an Origin header returns 204 No Content; the CORS headers above are added only when the origin is allowed.
All responses include:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
With ALLOW_EMBED=true the two framing controls are dropped, X-Frame-Options entirely and frame-ancestors 'none' out of the CSP, so any origin can put the dashboard in an iframe (workspace browsers, Home Assistant). Nothing else about the policy changes.
HSTS (Strict-Transport-Security: max-age=63072000; includeSubDomains; preload) is set only when this process terminated the TLS connection itself. Behind a reverse proxy that terminates TLS and forwards plain HTTP, the gateway does not set it, because a cached HSTS pin would point browsers at an HTTPS listener that does not exist; set the header on the proxy instead.
