-
Notifications
You must be signed in to change notification settings - Fork 3
API Reference
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 (e.g. sk-a1b2c3d4e5f6a7b8). Keys are created via the Admin API and are shown only once at creation time.
The native Anthropic endpoint /v1/messages additionally accepts the virtual key in the x-api-key header (what the anthropic SDKs send); Authorization: Bearer works on every endpoint.
| 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 (x-api-key or Bearer) |
Anthropic Messages API (translation + native passthrough) |
/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.
Returns the model list in OpenAI-compatible format.
Response:
{
"object": "list",
"data": [
{
"id": "gpt-4o",
"object": "model",
"created": 1234567890,
"owned_by": "openai"
}
]
}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/gpt-4o",
"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: The proxy automatically injects stream_options: {include_usage: true} for all streaming requests to ensure token usage is reported. |
Model Routing:
-
hotel/<model>- Failover routing (tries all providers that offer the model, with automatic failover on 5xx and optionally on 429) -
<provider>/<model>- Direct routing to a specific named provider (no failover)
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.
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, and Anthropic-shaped errors are all supported. Token usage is metered the same way as chat 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/429/401/403/404 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. Token metering is best-effort: providers reporting usage.total_tokens (Jina, Voyage) are metered, Cohere's search-unit billing meters as zero tokens.
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.mp3The response is the provider's raw binary audio (Content-Type passed through, e.g. audio/mpeg), streamed with no buffering. 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 file=@speech.mp3multipart/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"
}
}Requires the admin token for all management operations.
Authorization: Bearer <admin-token>
The admin token is generated on first startup and saved to .data/admin-token. It is shown only once in the startup logs.
| 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) |
/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 |
GET | List unseen model changes recorded by background (scheduled/startup) discovery |
/api/discovery/changes/ack |
POST | Mark recorded background changes as seen (clears the Models nav badge); returns the acked entries |
Response:
[
{
"id": "uuid",
"name": "OpenAI",
"base_url": "https://api.openai.com/v1",
"enabled": true,
"model_count": 15,
"total_tokens": 1234567,
"last_discovered_at": "2024-01-01T00:00:00Z"
}
]
Request Body:
{
"name": "OpenAI",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-..."
}| Field | Type | Required | Constraints |
|---|---|---|---|
name |
string | Yes | 1-100 characters, unique |
base_url |
string | Yes | 1-500 characters, must use HTTPS unless ALLOW_HTTP_PROVIDERS=true
|
api_key |
string | No | 1-500 characters (required for most providers, optional for Ollama, KoboldCPP, LMStudio, OpenCode Zen, custom) |
Response: 201 Created with provider object
Request Body: All fields optional for partial update. Accepts name, base_url, api_key, and enabled - unlike POST which does not accept enabled.
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"}],
"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": "gpt-4o", "removed_model_ids": ["uuid-old"], "added_model_ids": ["uuid-new"]}
]
}
}The diff summarizes what the scan changed (all sections omitted when empty). Reasons are machine-readable codes: new_model, reappeared, not_listed. Failover groups of newly disabled models are re-synced as part of the scan; the resulting group changes appear in the two failover_* sections. The dashboard shows this diff as a post-scan summary modal.
Returns usage/quota information for supported providers.
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)
Response (Z.AI example):
{
"total_quota": 1000000,
"used_quota": 50000,
"remaining_quota": 950000
}Returns balance information for supported providers.
Supported providers:
deepseek
Response:
{
"balance": 100.50,
"currency": "CNY"
}Returns account information for supported providers.
Supported providers:
ollama-cloud
Response:
{
"account_id": "...",
"email": "...",
"credits_remaining": 1000000
}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= filter) |
/api/models/cursor |
GET | Cursor-paginated model listing (keyset pagination for large catalogs) |
/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 |
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
provider_id |
UUID | Filter by provider UUID |
Response:
[
{
"id": "uuid",
"model_id": "gpt-4o",
"name": "GPT-4o",
"display_name": "GPT-4o",
"provider_id": "uuid",
"provider_name": "OpenAI",
"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,
"owned_by": "openai",
"description": "Most capable model",
"params": {"temperature": 0.7},
"modality": "text",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"enabled": true,
"created_at": "2024-01-01T00:00:00Z",
"last_seen_at": "2024-01-01T00:00:00Z"
}
]
Request Body: (all fields optional)
{
"display_name": "Custom Name",
"context_length": 128000,
"max_output_tokens": 16384,
"input_price_per_million": 5.0,
"output_price_per_million": 15.0,
"enabled": true
}Validation:
-
display_name: 1-128 characters -
context_length: 256-2000000 -
max_output_tokens: 1-128000 -
input_price_per_million: 0-1000 -
output_price_per_million: 0-1000
Response: 204 No Content
Tests a model by sending a minimal prompt and measuring response.
Response:
{
"success": true,
"duration_ms": 234,
"ttft_ms": 123,
"response": "Hi"
}On error:
{
"success": 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/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": "gpt-4o",
"display_name": "GPT-4o Failover",
"description": "Primary failover group",
"group_enabled": true,
"auto_created": false,
"entries": [
{
"model_uuid": "uuid",
"model_id": "gpt-4o",
"provider_name": "OpenAI",
"display_name": "GPT-4o",
"enabled": true,
"model_enabled": true,
"provider_enabled": true,
"context_length": 128000
}
],
"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"
}
Request Body:
{
"display_model": "gpt-4o",
"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",
"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 |
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 |
Validation: At least one entry must be enabled for an active failover group.
Find which failover group contains a given model UUID.
Response:
{
"id": "uuid",
"display_model": "gpt-4o",
"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": [],
"purged_entries": [],
"sync_errors": []
}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"
}
]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-...ab",
"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
}
]
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 (null = use global default) |
rate_limit_burst |
integer | No | Burst capacity (null = use global default, must be >= 1 if set) |
rate_limit_tpm |
integer | No | Tokens-per-minute cap (null = no cap / global default, must be >= 1 if set). Counts prompt + completion + reasoning; over-budget keys get 429 token rate limit exceeded with Retry-After
|
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 |
Response: 201 Created
{
"id": "uuid",
"name": "Production Key",
"key": "sk-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"key_preview": "sk-...d6",
"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
}
β οΈ 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 (keyset pagination) |
/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 using a
globalLogsCachekeyed by the raw query string. The response includes anX-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 |
status_code |
string | - | Filter by status code (4xx, 5xx, or exact integer; 0 = no response) |
endpoint_type |
string | - | Filter by endpoint family: chat, embeddings, image, tts, stt (unknown values are ignored) |
from |
RFC3339 | - | Start timestamp |
to |
RFC3339 | - | End timestamp |
sort_by |
string | time |
Sort column: time, model, provider, status, tokens, tps, ttft, duration, overhead, key
|
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,
"streaming": true,
"virtual_key_name": "Production Key",
"virtual_key_deleted": false,
"virtual_key_id": "uuid",
"error_message": "",
"error_kind": "",
"failover_attempt": 0,
"state": "completed",
"endpoint_type": "chat",
"created_at": "2024-01-01T00:00:00Z"
}
],
"total": 1000,
"page": 1,
"per_page": 20
}
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 (keyset pagination) |
/api/logs/app |
DELETE | Clear all app logs (ring buffer + DB) |
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
}
}Response:
{ "deleted": 1234 }| 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} |
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"
}
]
Creates a PostgreSQL backup using pg_dump.
Response: 201 Created
{
"filename": "backup_20240101_120000_123456.dump",
"size_bytes": 10485760,
"created_at": "2024-01-01T12:00:00Z"
}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
Response: 204 No Content
Preview which backups would be pruned under the son/father/grandfather rotation scheme. 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. |
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",
"theme": "dark",
"accent_color": "#1dd1a1"
}
Request Body:
{
"rate_limit_ip_rps": "50",
"discovery_interval": "12h",
"theme": "light"
}Allowed Settings:
| Key | Type | Description |
|---|---|---|
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 |
request_timeout |
string | Duration (e.g. "1m0s") |
failover_on_rate_limit |
string |
"true" or "false"
|
circuit_breaker_enabled |
string |
"true" or "false"
|
circuit_breaker_threshold |
int | 1-100 |
circuit_breaker_cooldown |
string | Duration |
discovery_interval |
string | Duration (e.g. "6h", "0" = disabled) |
discovery_on_startup |
string |
"true" or "false"
|
discovery_on_provider_create |
string |
"true" or "false"
|
log_retention |
string |
"1h", "1d"/"24h", "1w"/"168h", "1m"/"720h", "0" (disable), 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) |
backup_enabled |
string |
"true" or "false" (periodic backup with rotation) |
backup_interval |
string | Duration between automatic backups (minimum 300s) |
backup_son_retention |
int | 0-365 (daily tier) |
backup_father_retention |
int | 0-52 (weekly tier) |
backup_grandfather_retention |
int | 0-120 (monthly tier) |
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
|
exclude_deleted |
boolean | true |
Exclude deleted providers/keys |
metric |
string | requests |
Metric for aggregation: requests or tokens
|
Response:
{
"total_requests_last_24h": 12345,
"total_requests_last_7d": 54321,
"by_model": {
"gpt-4o": 5000,
"claude-3-opus": 3000
},
"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,
"avg_tokens_per_request": 101.2,
"rate_limit_hits": 15,
"avg_ttft_ms": 123.4,
"requests_last_1h": 500
}Query Parameters: Same as /api/stats
Response:
{
"points": [
{
"bucket": "2024-01-01T00:00:00Z",
"count": 100,
"tokens": 50000,
"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
|
exclude_deleted |
boolean | true |
Exclude deleted providers/keys |
metric |
string | requests |
Distribution metric: requests or tokens
|
Response:
{
"items": [
{
"name": "OpenAI",
"count": 8000,
"tokens": 0,
"share": 66.7
},
{
"name": "Anthropic",
"count": 4000,
"tokens": 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,
"tx_per_sec": 15.3,
"dead_tuples": 1000,
"lock_waits": 0
},
"docker": {
"cpu_percent": 5.0,
"memory_usage_bytes": 536870912,
"memory_limit_bytes": 2147483648,
"net_rx_bytes": 1048576,
"net_tx_bytes": 2097152,
"block_read_bytes": 524288,
"block_write_bytes": 262144
}
}| Endpoint | Method | Description |
|---|---|---|
/api/events |
GET | Server-Sent Events stream (requires admin token) |
Long-lived SSE stream for real-time dashboard updates. Requires admin token in Authorization header.
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"}}
: heartbeat
Event Types:
| Event | Severity | Description |
|---|---|---|
discovery.complete |
success/warning/error
|
Model discovery finished for a provider |
discovery.provider_fetched |
success |
Fetched models from a provider |
discovery.provider_failed |
error |
Discovery failed for a provider |
discovery.enriched |
info |
Models enriched from 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) |
failover.sync_error |
warning |
Error during failover group synchronization |
circuit_breaker.open |
warning |
Provider circuit breaker opened |
circuit_breaker.half-open |
info |
Circuit breaker probing |
circuit_breaker.closed |
success |
Circuit breaker closed (recovered) |
tokens.error |
error |
Error counting tokens |
backup.created |
success |
Database backup created (manual or scheduled) |
backup.deleted |
info |
Backup deleted |
backup.pruned |
info |
Backup pruned by rotation |
request.discovery.provider_starting |
info |
Starting discovery for a provider |
Heartbeat comments (: heartbeat) are sent every 30 seconds.
Available only when WEBAUTHN_RP_ID is configured (see Security for the full authentication flow).
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/webauthn/available |
GET | None (public) | Check if WebAuthn is enabled ({"enabled": true/false}) |
/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/false}) |
/api/totp/login |
POST | IP rate-limited | Exchange admin token + 6-digit code (or a recovery code) for a session token |
/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 |
/api/totp/disable |
POST | Admin/session token | Disable TOTP (gated on a current code or recovery code) |
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 use admin token authentication instead of a virtual key. They support the same streaming and non-streaming modes. Rate limiting applies per-IP on these routes.
Request/Response: Same format as /v1/chat/completions
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/health |
GET | None | Returns OK with HTTP 200 |
Response:
OK
This endpoint is intended for load balancer health checks and monitoring. No authentication is required.
{
"error": {
"message": "Error description",
"type": "error_type"
}
}| Code | Description | Common Causes |
|---|---|---|
200 |
OK | Successful request |
201 |
Created | Resource created successfully |
204 |
No Content | Successful deletion |
400 |
Bad Request | Invalid request body, validation errors |
401 |
Unauthorized | Missing or invalid authentication |
404 |
Not Found | Resource not found |
409 |
Conflict | Duplicate resource, operation in progress |
412 |
Precondition Failed | Missing dependency (e.g. pg_dump) |
429 |
Too Many Requests | Rate limit exceeded |
500 |
Internal Server Error | Server error |
502 |
Bad Gateway | Upstream provider error |
Invalid Virtual Key:
{
"error": {
"message": "Invalid virtual key",
"type": "authentication_error"
}
}Rate Limit Exceeded:
{
"error": {
"message": "Rate limit exceeded",
"type": "rate_limit_error"
}
}Model Not Found:
{
"error": {
"message": "Model not found: hotel/gpt-5",
"type": "model_not_found"
}
}Upstream Provider Error:
{
"error": {
"message": "Upstream provider returned error: Invalid API key",
"type": "upstream_error",
"provider": "openai"
}
}| Route Group | Auth Method | Token Format |
|---|---|---|
/v1/* |
Virtual Key | Bearer sk-... |
/api/* |
Admin Token (or WebAuthn/TOTP session) | Bearer <admin-token> |
/api/events |
Admin Token (or WebAuthn/TOTP session) | Bearer <admin-token> |
/api/chat/* |
Admin Token (or WebAuthn/TOTP session) | Bearer <admin-token> |
/api/webauthn/available, /api/webauthn/login/*
|
None (IP rate-limited) | - |
/api/totp/status |
None (public) | - |
/api/totp/login |
None (IP rate-limited) | - |
/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
Applied to all routes, 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, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Preflight: OPTIONS requests return 204 No Content 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'
HSTS (Strict-Transport-Security) is set only over HTTPS connections.
Last synced from hugalafutro/model-hotel@3b28720 on 2026-07-08 20:04 UTC. Edit these pages under wiki/ (and images under docs/screenshots/) in the main repo, not here.
- π¨ Home
- βοΈ Configuration
- π Development
- π Virtual Keys
- π API Reference
- π Request Logging
- π Model Discovery
- π Failover and Hotel Routing
- π Alerting
- π¨ High Availability