# REST API reference > The management API under /admin/api and the OpenAI-compatible client API under /v1: authentication, the role each endpoint needs, examples and the error envelope. Source: https://ragmux.com/docs/api/ Updated: 2026-10-11 Project: https://github.com/ragmux/ragmux (AGPL-3.0-or-later) --- Ragmux exposes three HTTP surfaces: | Prefix | Audience | Authentication | |---|---|---| | `/v1/…` | your applications (OpenAI-compatible) | `Authorization: Bearer sk-proj-…` project key, or a user's `sk-user-…` key | | `/admin/api/…` | dashboard and management scripts | session cookie, bearer session token, or a user's `sk-mgmt-…` key | | `/healthz` | load balancers, container healthchecks | none | All request and response bodies are JSON unless noted. `/admin/` (without `api`) serves the embedded dashboard. ## Authentication ### Management API `POST /admin/api/login` with `{"username": "...", "password": "...", "bearer": true}` returns ```json {"token": "…", "user": {"id": 1, "username": "admin", "role": "admin", "is_active": true, "last_login_at": "2026-09-18T10:00:00Z", "created_at": "…"}} ``` and sets the `ragmux_session` cookie (`HttpOnly`, `SameSite=Lax`, `Secure` on HTTPS or with `SECURE_COOKIES=true`). The `token` field is only included when the request sets `"bearer": true`; the dashboard omits it and relies on the cookie alone. Every other `/admin/api` route accepts either that cookie or `Authorization: Bearer `; the bearer header wins when both are present. Sessions expire after `SESSION_TTL` (default 24 h) and are revoked by `POST /admin/api/logout`, a password change (all other sessions of the account), a password reset, a session revoke or deactivation of the account. ```bash TOKEN=$(curl -s localhost:8765/admin/api/login -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"YOUR_PASSWORD","bearer":true}' | jq -r .token) AUTH="Authorization: Bearer $TOKEN" ``` Requests authenticated by the **cookie** are subject to two browser-oriented checks that bearer requests skip: state-changing methods (`POST`, `PUT`, `DELETE`) must come from the same origin (`Sec-Fetch-Site: same-origin`/`none`, or an `Origin` whose host equals the request `Host`; otherwise `403 {"error":{"message":"cross-site request rejected","type":"forbidden"}}`), and JSON bodies must be sent as `Content-Type: application/json` (otherwise `415`). The login request itself always needs the JSON content type. Scripts should use the bearer token. A **management key** (`sk-mgmt-…`, see [API keys](#api-keys)) authenticates the same routes, but only from the `Authorization` header. One pasted into the `ragmux_session` cookie is refused `401` without even being looked up. That is deliberate and is what keeps the bearer exemption above sound for keys: a browser attaches cookies to cross-site requests on its own, whereas it cannot attach an `Authorization` header cross-origin without a CORS preflight, and Ragmux only answers preflights for the origins in `CORS_ORIGINS` (empty by default). The premise behind the existing exemption therefore holds identically for management keys, and nothing about the cookie path is loosened. Three routes refuse an api-key principal outright with `403 {"error":{"code":"session_required"}}` — `POST /me/password`, `POST /users/{id}/reset-password` and creating a `kind=management` key — so a leaked key cannot take over the account it belongs to. **What that guard assumes.** It treats a session as a person at a keyboard and an api key as a stored credential, and grants the account-takeover routes only to the first. `"bearer": true` weakens the premise: the session token leaves the browser as a string in a JSON body, and a string can be pasted into a script, a CI secret or a chat window, at which point a "session" is doing exactly what the guard withholds from keys. The difference that remains is lifetime and revocation, not interactivity: a session token expires after `SESSION_TTL` (24 h by default), dies with a logout, a password change, a password reset, a session revoke or a deactivation, and cannot be listed, named or scoped — an api key outlives all of that until someone revokes it. Treat a bearer session token as a short-lived credential, keep it out of anything durable, and use a `sk-mgmt-…` key for automation that is supposed to persist; if a script genuinely needs one of the three routes above, it needs a human to sign in for it. Every `/admin/api` response carries `Cache-Control: no-store` and an `X-Request-Id`; unexpected failures answer `500 {"error":{"message":"internal error (request id …)"}}` and log the detail under that id. Login failures answer `401 {"error":{"message":"invalid username or password","type":"Unauthorized","attempts_remaining":4}}`, where `attempts_remaining` is how many more failures the username may record this minute before the per-user limit triggers (absent when that limit is disabled). It is derived from the recorded attempts alone, so an unknown username and a wrong password get identical answers. Too many failures answer `429 {"error":{"message":"too many login attempts, try again later","type":"rate_limited","locked":false}}` with a `Retry-After` header; `locked` is `true` when the username/address lockout triggered rather than a per-minute budget (see [Users, roles and limits](/docs/users-and-limits/#login-protection)). Usernames are matched case-insensitively. ### Client API `/v1` routes require `Authorization: Bearer `, which is one of two things: - **`sk-proj-…`** — the project's default key, returned once when a project is created or its key is rotated. It selects exactly that project, and with it the model connection, system prompt, RAG store and limits. It has no owner, no scopes and no expiry, and can only be rotated, not revoked. - **`sk-user-…`** — a user-owned gateway key (see [API keys](#api-keys)). It carries a set of granted projects, its own scopes and its own limits under the project's, and can be revoked or given an expiry. Missing or unknown keys answer `401` with type `invalid_request_error` (no bearer header) or `invalid_api_key`. An unknown key's body is exactly `{"error":{"message":"invalid project api key","type":"invalid_api_key","code":null}}` whatever its shape. A key that *is* known but no longer resolves carries a `code` on top — `key_revoked`, `key_expired` or `key_owner_inactive` — so the extra detail only ever reaches a caller who already holds the key bytes. #### Choosing a project A `sk-user-…` key may grant several projects, so each request resolves one, in this order: 1. the `X-Ragmux-Project` request header, matched against the granted project names, or against their ids when the value is numeric; 2. the key's `default_project_id`; 3. the single grant, when the key has exactly one. If none of those settles it, `POST /v1/chat/completions` answers `400 {"error":{"code":"project_required"}}` with an `X-Ragmux-Projects` response header listing the valid values (`prod,staging`). A header naming a project the key does not grant answers `403 {"error":{"code":"project_not_granted"}}` with a fixed message that does not distinguish "no such project" from "not granted", so a key holder cannot probe for project names. A key whose last grant was deleted has an empty grant set and gets the same `403`: it fails closed rather than falling back to anything. `GET /v1/models` is the exception: with several grants and no header it lists one entry per granted project's connection, deduplicated by model name, and `GET /v1/models/{id}` then has to name one of them. There is deliberately **no `"project/model"` convention** in the `model` field. `model_name` legitimately contains slashes (`org/model-1.5:latest@v2_x` is a valid model name), so the split would be ambiguous and would silently reroute clients that already send such a name. Use the header. The grants on the key, not the owner's project memberships, are what `/v1` enforces. Membership is checked once, when the key is created or updated; after that an admin editing a member list cannot break a running key. ## Roles The **Role** column in the tables below is the minimum role required (`viewer` < `editor` < `admin`). `member` means the caller must be an `admin` or a member of the project; non-members get `404` so project ids are not enumerable. Requests below the required role answer `403 {"error":{"message":"insufficient role","type":"forbidden"}}`; requests without a valid session answer `401 {"error":{"message":"authentication required","type":"unauthorized"}}`. ## Error envelope Management endpoints answer errors as ```json {"error": {"message": "chunk_size must be between 200 and 20000", "type": "Bad Request"}} ``` where `type` is the HTTP status text: `Bad Request` (validation), `Not Found`, `Conflict` (duplicate name, or an item still referenced by a project, RAG store or route), `Forbidden`, `Internal Server Error`. Login limiting and the auth middleware use the lower-case types shown above. The client API uses the OpenAI shape with a `code` field: ```json {"error": {"message": "invalid project api key", "type": "invalid_api_key", "code": null}} ``` ## Management endpoints All paths are relative to `/admin/api`. ### First-run setup Unauthenticated by design; both refuse as soon as any user exists. | Method | Path | Role | Purpose | |---|---|---|---| | GET | `/setup` | — | While the `users` table is empty: `{"needs_setup": true, "migrations_version": N, "secret_key_source": "env", "database_role": "ragmux", "has_connections": false, "has_projects": false}` — the facts after `needs_setup` let the setup page confirm which database and key the gateway runs on (`secret_key_source` is `env` or `file`, `database_role` the connected PostgreSQL role, `N` the applied migration version, whatever this build has reached) and let the wizard resume at the right step. **Once any user exists the answer is `{"needs_setup": false}` and nothing else**: the endpoint is unauthenticated, and after setup an anonymous request has no business knowing the migration version, the database role or how far the install got | | POST | `/setup` | — | `{username, password, bearer?}` creates the first user with the `admin` role and logs it in (session cookie; `token` in the body when `bearer` is true) → `201 {user}`. Username: 3–64 characters of `a-z 0-9 . _ -`; password: at least 12 characters and at most 72 bytes. `409 setup already completed` once a user exists, also for a concurrent request that lost the race. Failed attempts count against the per-address login limit (`429` with `Retry-After`). Audited as `setup.complete`. | `ADMIN_PASSWORD` pre-creates the account on start for unattended installs, in which case setup is already complete (see [Configuration](/docs/configuration/#environment-variables)). The dashboard's first-run wizard has three steps, but only this first one is unauthenticated. Step 2 (a model connection) and step 3 (a project) are ordinary authenticated calls to `POST /api/models` and `POST /api/projects` made with the session the first step established, so they are role-gated and audited like any other write and add no new unauthenticated surface. Both are skippable. A reload during step 2 or 3 picks the step back up from `GET /api/models`, which the session can already read — not from `GET /setup`, which by then answers `needs_setup` alone. ### Session and account | Method | Path | Role | Purpose | |---|---|---|---| | POST | `/login` | — | `{username, password, bearer?}` → `{user}` plus `token` when `bearer` is true; `400` when the username (1–64 characters) or password (1–1024) is missing or too long; the username is matched case-insensitively | | POST | `/logout` | viewer | Ends the session → `{"ok": true}` | | GET | `/me` | viewer | Current user `{id, username, role, is_active, last_login_at, created_at}` plus `session_expires_at` (RFC 3339, empty for a key without an expiry), `session_bearer` (`true` when the request carried an `Authorization` header rather than the cookie), `session_kind` (`"session"` or `"api_key"`) and, for a key, its `scopes` | | POST | `/me/password` | viewer | `{current_password, new_password}` (at least 12 characters, at most 72 bytes) → `{"ok": true}`; `403` when the current password is wrong; every other session of the account is revoked. Session-only: an api key gets `403 session_required` | | GET | `/provider-types` | viewer | Supported provider types with `type`, `label`, `default_base_url`, `supports_embeddings`, `requires_api_key`, `supports_tools`, `supports_streaming`, and a `capabilities` object (`chat`, `streaming`, `embeddings`, `tools`, `tool_streaming`, `vision`, `remote_images`, `prompt_caching`, `cached_token_usage`, `rerank`) — see [Capabilities](/docs/providers/#capabilities) | | GET | `/provider-presets` | viewer | Ready-made `custom_openai` setups for hosted OpenAI-compatible providers (Mistral, xAI, Groq), each with `id`, `label`, `provider_type`, `base_url`, suggested `models`, an optional `embedding_model` and `requires_api_key`. A preset is not stored: create the connection with `POST /models` using its `provider_type` and `base_url`. No preset carries a price — see [Presets](/docs/providers/#presets) | `GET /provider-types` answers an array with one object per type: ```json [{"type": "openai", "label": "OpenAI", "default_base_url": "https://api.openai.com/v1", "supports_embeddings": true, "requires_api_key": true, "supports_tools": true, "supports_streaming": true, "capabilities": {"chat": true, "streaming": true, "embeddings": true, "tools": true, "tool_streaming": true, "vision": true, "remote_images": true, "prompt_caching": false, "cached_token_usage": true, "rerank": false}}] ``` `GET /provider-presets` answers an array: ```json [{"id": "mistral", "label": "Mistral AI", "provider_type": "custom_openai", "base_url": "https://api.mistral.ai/v1", "models": ["mistral-large-latest", "mistral-small-latest"], "embedding_model": "mistral-embed", "requires_api_key": true}] ``` ### Model connections | Method | Path | Role | Purpose | |---|---|---|---| | GET | `/models` | viewer | List connections | | POST | `/models` | editor | Create → `201` connection | | GET | `/models/{id}` | viewer | Read one | | PUT | `/models/{id}` | editor | Update; an empty `api_key` keeps the stored key | | DELETE | `/models/{id}` | editor | Delete → `{"ok": true}`; `409` while a project, RAG store or route target uses it | | POST | `/models/{id}/test` | editor | `{mode?}`, where `mode` is `"chat"` (default) or `"embedding"`; spends provider quota, so it is a write; the outcome is recorded on the connection | | POST | `/models/test` | editor | Test an unsaved connection: the create body plus `mode` and optionally `connection_id` (reuse that connection's stored key when `api_key` is empty); nothing is recorded | Request body for create and update: ```json {"name": "claude", "provider_type": "anthropic", "api_key": "sk-ant-...", "model_name": "claude-sonnet-4-5", "base_url": ""} ``` `provider_type` is one of `openai`, `anthropic`, `gemini`, `deepseek`, `ollama`, `custom_openai`, `azure_openai`, `cohere_rerank` or `voyage_rerank`. `azure_openai` needs a `base_url` (the resource endpoint, which may carry an `api-version` query parameter) and `model_name` is its deployment name, see [Azure OpenAI](/docs/providers/#azure-openai); the two rerank types can serve only rerank targets and RAG store reranking, see [Rerank providers](/docs/providers/#rerank-providers-cohere_rerank-voyage_rerank). `base_url` must be an `http://` or `https://` URL with a host and without credentials or fragment (and without a query string, except the `api-version` of `azure_openai`), and is required for `custom_openai` and `azure_openai`; a host that resolves only to private or local addresses is rejected with `400` unless allowed by `PRIVATE_UPSTREAM_ALLOWLIST` / `ALLOW_PRIVATE_UPSTREAMS` (see [Configuration](/docs/configuration/#private-upstreams)). `model_name` may contain letters, digits and `. _ : / @ -` (up to 128 characters, no leading `/`, no `..`). The response never contains the key: ```json {"id": 1, "name": "claude", "provider_type": "anthropic", "base_url": "", "api_key_masked": "sk-ant-…abcd", "model_name": "claude-sonnet-4-5", "private_upstream": false, "last_test_at": "2026-09-18T10:05:00Z", "last_test_ok": true, "last_test_latency_ms": 812, "last_test_error": "", "created_at": "2026-09-18T10:00:00.000Z", "updated_at": "2026-09-18T10:00:00.000Z"} ``` `private_upstream` is true when `base_url` points at `localhost`, a loopback, private or link-local address, or a hostname that resolved only to such addresses when the connection was saved (it is computed on create and update, never on read; the provider default URL counts as public). `last_test_*` describe the most recent `POST /models/{id}/test`: all `null` / empty until the connection has been tested. `POST /models/{id}/test` always answers `200` and reports the outcome in the body: `{"ok": true, "reply": "pong", "usage": {…}, "latency_ms": 812}` for chat (the model is asked to reply with the word `pong`, `max_tokens` 256), `{"ok": true, "dimensions": 1536, "latency_ms": 240}` for embeddings, or `{"ok": false, "error": "...", "latency_ms": …}`. The result is stored on the connection as `last_test_at`, `last_test_ok`, `last_test_latency_ms` and `last_test_error` (credentials redacted, at most 512 characters). `POST /models/test` runs the same test on a connection that has not been saved: ```json {"provider_type": "custom_openai", "base_url": "http://vllm:8000/v1", "api_key": "", "model_name": "llama-3", "mode": "chat", "connection_id": 4} ``` `base_url`, `provider_type` and `model_name` are validated exactly like create (including the private-address check → `400`); `name` is not required. With `connection_id` set and `api_key` empty the stored key of that connection is used, so an edit form can try a changed URL or model without re-entering the key. The response has the same shape as `/models/{id}/test`; nothing is recorded. ### RAG stores and documents | Method | Path | Role | Purpose | |---|---|---|---| | GET | `/rag-stores` | viewer | List stores (with `document_count`, `bytes_used`, `chunk_count`, `dimensions`) | | POST | `/rag-stores` | editor | Create → `201` store | | GET | `/rag-stores/{id}` | viewer | Read one | | PUT | `/rag-stores/{id}` | editor | Update → store plus `reprocess_recommended` | | DELETE | `/rag-stores/{id}` | editor | Delete the store, its documents and vectors; projects linked to it lose the link | | GET | `/rag-stores/{id}/documents` | viewer | List documents, newest first | | POST | `/rag-stores/{id}/documents` | editor | Upload (`multipart/form-data`, one or more `file` fields) → `202` document or array of documents; `422` with `code: "store_quota"` when the store's `max_documents` / `max_bytes` or the instance ceiling would be exceeded | | POST | `/rag-stores/{id}/search` | viewer | Retrieval test | | POST | `/rag-stores/{id}/reprocess` | editor | Re-parse, re-chunk and re-embed every document → `202 {"ok": true, "documents": n}` | | GET | `/documents/{id}` | viewer | Document status | | DELETE | `/documents/{id}` | editor | Delete document and its chunks → `{"ok": true}` | | POST | `/documents/{id}/reprocess` | editor | Re-chunk and re-embed one document → `202 {"ok": true}` | Store body (all fields except `name` and `embedding_connection_id` optional; the retrieval semantics are explained in [Retrieval (RAG)](/docs/rag/)): ```json {"name": "handbook", "embedding_connection_id": 2, "chunk_size": 1000, "chunk_overlap": 200, "top_k": 5, "search_mode": "hybrid", "search_backend": "pgvector", "fts_config": "simple", "rerank": false, "rerank_backend": "llm", "rerank_connection_id": null, "rerank_candidates": 15, "max_distance": 0, "contextual_chunks": true, "max_documents": 0, "max_bytes": 0} ``` Validation: `chunk_size` 200-20000, `chunk_overlap` ≥ 0 and smaller than `chunk_size`, `top_k` ≤ 50, `search_mode` `vector` or `hybrid`, `search_backend` `pgvector` or `pg_search` (`400` when this server cannot run it, see [Search backends](/docs/rag/#search-backends)), `fts_config` must exist in `pg_ts_config` (anything but `simple` is accepted and, on the `pgvector` backend, logged as a warning, see [Search modes](/docs/rag/#search-modes)), `rerank_backend` `llm`, `cohere` or `voyage` (`cohere` and `voyage` need a `rerank_connection_id` of the matching `cohere_rerank` / `voyage_rerank` type; `llm` may name a chat connection, see [Reranking](/docs/rag/#reranking)), `rerank_candidates` 1-100, `max_distance` 0-2, `max_documents` and `max_bytes` ≥ 0 (`0` = unlimited, see [Unlimited (`0`)](/docs/configuration/#zero-means-unlimited)). The embedding connection must be of a type that supports embeddings, must produce vectors of at most 2000 dimensions (`400` when the probe at save time can tell; otherwise the first ingest fails the document, see [Embedding width](/docs/rag/#embedding-width)) and cannot be changed while the store has chunks (`400`). Responses carry the usage the quotas are checked against: `document_count` and `bytes_used`. A store as the API returns it: ```json {"id": 1, "name": "handbook", "embedding_connection_id": 2, "chunk_size": 1000, "chunk_overlap": 200, "top_k": 5, "search_mode": "hybrid", "search_backend": "pgvector", "fts_config": "simple", "rerank": false, "rerank_candidates": 15, "rerank_backend": "llm", "rerank_connection_id": null, "max_distance": 0, "contextual_chunks": true, "max_documents": 0, "max_bytes": 0, "dimensions": 1536, "document_count": 3, "bytes_used": 3145728, "chunk_count": 126, "created_at": "2026-09-18T10:00:00.000Z"} ``` `dimensions` is the width of the store's vectors (`0` until the first ingest), and `chunk_count` the number of chunks across its documents. `PUT` adds `reprocess_recommended` to the object: `true` when a changed setting only takes effect for documents after they are re-chunked or re-embedded. Documents look like ```json {"id": 7, "rag_store_id": 1, "filename": "handbook.pdf", "mime": "application/pdf", "size_bytes": 1048576, "status": "ready", "error": "", "chunk_count": 42, "page_count": 12, "progress_percent": 100, "attempts": 1, "created_at": "…", "updated_at": "…"} ``` with `status` moving `pending` → `processing` → `ready` (or `failed` with `error` set). `progress_percent` follows ingestion: `10` once parsed, `20` once chunked, then the share of embedding batches completed, `100` when ready; a failed document keeps the value it reached. `page_count` is the number of pages of a PDF and `null` for other formats (and until the document has been parsed). `attempts` counts how often a replica claimed the document for ingestion since it was last queued; a document past the cap is skipped rather than retried forever. Accepted extensions: `.pdf`, `.docx`, `.html`, `.htm`, `.md`, `.markdown`, `.txt`; the request body is limited to `MAX_UPLOAD_MB`. Upload and reprocess answer `503 the ingestion backlog is full (MAX_PENDING_DOCUMENTS), retry later` when the queue of `pending` documents is at `MAX_PENDING_DOCUMENTS` (1024); the documents concerned are marked `failed` with that message. The backlog is counted across every replica, not per process. Search request and response: ```json POST /admin/api/rag-stores/1/search {"query": "vacation policy", "top_k": 3, "mode": "hybrid", "rerank": true, "max_distance": 0.6} {"mode": "hybrid", "backend": "pgvector", "fts_config": "simple", "reranked": true, "rerank_backend": "llm", "rerank_fallback": false, "latency_ms": 412, "retrieval_latency_ms": 180, "rerank_latency_ms": 230, "hits": [{"chunk_id": 8, "document_id": 2, "filename": "handbook.pdf", "index": 3, "section": "Leave > Vacation", "page": 12, "content": "…", "distance": 0.18, "score": 0.0325, "vector_rank": 1, "fts_rank": 2, "lex_score": 7.43}]} ``` `lex_score` is the raw BM25 score under the `pg_search` backend and is absent under `pgvector` ([Search endpoint](/docs/rag/#search-endpoint)). Everything but `query` is optional and overrides the store setting for this call only (`mode`, `backend`, `rerank`, `rerank_backend`, `rerank_connection_id`, `max_distance` and `top_k`; the full list and the meaning of the response fields are in [Search endpoint](/docs/rag/#search-endpoint)); `top_k` is clamped to 1-50 (`0` or absent uses the store's `top_k`). `latency_ms` is the whole call, `retrieval_latency_ms` covers embedding the query and the database search, and `rerank_latency_ms` the reranker (`null` when `rerank` is off or was skipped; a number, often `0`, when the reranker was tried and fell back, with `rerank_fallback: true`). When `rerank` is on but the store has no `rerank_connection_id` (and so nothing to rerank with), the search runs unreranked and the response says so with `"rerank_skipped": true` and a `rerank_skipped_reason`; both are `false` / `""` otherwise. A `502` is returned when the embedding call fails. ### Projects | Method | Path | Role | Purpose | |---|---|---|---| | GET | `/projects` | viewer | Projects the caller is a member of (admins: all) | | POST | `/projects` | editor | Create → `201 {"project": …, "api_key": "sk-proj-…"}` (key shown once; the creator becomes a member) | | GET | `/projects/{id}` | member | Read one | | PUT | `/projects/{id}` | member + editor | Update (full replace of the fields below) | | DELETE | `/projects/{id}` | member + editor | Delete with its request logs → `{"ok": true}` | | POST | `/projects/{id}/rotate-key` | member + editor | New key → `{"project": …, "api_key": "sk-proj-…"}`; the old key stops working immediately | | GET | `/projects/{id}/members` | member | `[{id, username, role, added_at}]` | | PUT | `/projects/{id}/members` | member + editor | `{user_ids}` (an array such as `[1, 4]`) replaces the member set; an editor cannot remove themselves | | GET | `/projects/{id}/metrics?window=24h` | member | Metrics for one project (see below; takes the same `compare`, `days` and `by_project` options as the summary) | | GET | `/projects/{id}/metrics.csv?window=24h` | member | The project's request logs in the window as a CSV download (see below) | | GET | `/projects/{id}/usage` | member | Live rate-limit and budget counters with a budget forecast | Project body: ```json {"name": "support-bot", "model_connection_id": 1, "rag_store_id": 1, "system_prompt": "You are the company support assistant.", "member_user_ids": [4], "rate_limit_rpm": 0, "rate_limit_tpm": 0, "budget_daily_tokens": 0, "budget_monthly_tokens": 0} ``` `rag_store_id` may be `null` or `0` for no store; limits are `0` for unlimited (see [Zero means unlimited](/docs/configuration/#zero-means-unlimited)). `member_user_ids` is only read on create (use the members endpoint afterwards). The full key is never returned again. The project object: ```json {"id": 3, "name": "support-bot", "model_connection_id": 1, "rag_store_id": 1, "api_key_prefix": "sk-proj-ab12", "system_prompt": "You are the company support assistant.", "rate_limit_rpm": 0, "rate_limit_tpm": 0, "budget_daily_tokens": 0, "budget_monthly_tokens": 0, "member_ids": [4], "created_at": "…", "updated_at": "…"} ``` `api_key_prefix` holds the first characters of the key for identification. `GET /projects/{id}/usage`: ```json {"project_id": 3, "generated_at": "2026-09-18T12:00:30Z", "minute": {"period_start": "2026-09-18T12:00:00Z", "resets_at": "2026-09-18T12:01:00Z", "requests": 2, "prompt_tokens": 20, "completion_tokens": 4, "tokens": 24, "request_limit": 2, "request_percent": 100, "token_limit": 0, "token_percent": 0}, "day": {"…": "…", "tokens": 12, "token_limit": 5, "token_percent": 100, "resets_at": "2026-09-19T00:00:00Z"}, "month": {"…": "…"}, "forecast": {"daily_exhausted_at": "2026-09-18T15:40:00Z", "monthly_exhausted_at": null}} ``` `forecast` extends the tokens consumed in the last 60 minutes linearly and reports when each budget would run out at that pace. A value is `null` when the budget is not set, nothing was consumed in the last hour, or the projection lands after the window resets (`resets_at`); an already exhausted budget projects to `generated_at`. ### Routes A route is a named entry point inside a project: the client's `model` field selects it, and the route spreads requests over its targets. A project without chat routes works as before (see [Model field echo](/docs/providers/#model-field-echo)). Adding the first chat route changes what an unknown `model` string gets: read [Before your first chat route](/docs/providers/#before-your-first-chat-route) first. | Method | Path | Role | Purpose | |---|---|---|---| | GET | `/projects/{id}/routes` | member | List the project's routes with their targets | | POST | `/projects/{id}/routes` | member + editor | Create → `201` route | | GET | `/projects/{id}/routes/{routeId}` | member | Read one | | PUT | `/projects/{id}/routes/{routeId}` | member + editor | Replace the route and all of its targets in one transaction | | DELETE | `/projects/{id}/routes/{routeId}` | member + editor | Delete → `{"ok": true}` | Route body: ```json {"name": "smart", "kind": "chat", "strategy": "weighted", "targets": [ {"connection_id": 1, "model": "gpt-4.1", "weight": 3, "priority": 0}, {"connection_id": 2, "weight": 1, "priority": 0}]} ``` - `name` is 1 to 256 characters without control characters and is unique within the project (`409` otherwise). - `kind` is `chat` (default), `embeddings` or `rerank`. `strategy` is `weighted` (default), the only one. - `targets` holds 1 to 8 entries. `connection_id` must exist and its provider type must support the route's `kind`. `model` is optional: when set it replaces the connection's model name for calls through that target. `weight` is 1 to 1000 (default 1) and `priority` is 0 to 100 (default 0, lower first). The same connection and `model` pair may appear only once per route. - Invalid input is `400`. The response lists `targets` ordered by `priority`, then `id`, each with `connection_name` and `provider_type` next to the stored fields. A route as the API returns it (`model` is `null` for a target that keeps the connection's model name): ```json {"id": 5, "project_id": 3, "name": "smart", "kind": "chat", "strategy": "weighted", "targets": [ {"id": 11, "connection_id": 1, "connection_name": "openai-main", "provider_type": "openai", "model": "gpt-4.1", "weight": 3, "priority": 0}, {"id": 12, "connection_id": 2, "connection_name": "claude", "provider_type": "anthropic", "model": null, "weight": 1, "priority": 0}], "created_at": "…", "updated_at": "…"} ``` Candidate order for a call: groups by `priority` ascending; inside a group a weighted random order without replacement. The first candidate is called; a failure before the first byte is retried on it or hands the request to the next candidate, as [Retry and fallback](/docs/providers/#retry-and-fallback) describes, and a connection whose circuit breaker is open is skipped. Deleting a project removes its routes; a connection that a route target still uses cannot be deleted (`409`). Create, update and delete are audited as `route.create`, `route.update` and `route.delete`. Route and target names are never metric labels. ### API keys User-owned credentials, separate from a project's default key. Everyone manages their own; admins see and manage everyone's. A key someone else owns answers `404`, not `403`, so key ids are not enumerable. All of these need the `keys` scope when the caller is itself a management key. | Method | Path | Role | Purpose | |---|---|---|---| | GET | `/keys` | viewer | The caller's keys, newest first, each with the owner's `username`. Admins get every owner and may narrow with `?user_id=`; `?kind=gateway\|management` filters both. `user_id` is ignored for non-admins | | POST | `/keys` | viewer | Create → `201 {"key": {…}, "api_key": "sk-user-…"}`; the credential is returned once and never again | | GET | `/keys/{id}` | owner or admin | Read one | | PUT | `/keys/{id}` | owner or admin | Update the mutable fields (below); the credential and the `kind` never change — revoke and reissue instead | | POST | `/keys/{id}/revoke` | owner or admin | Sets `revoked_at` → the key object; effective on the next request | | GET | `/keys/{id}/usage` | owner or admin | The key's own counters, in the shape of `/projects/{id}/usage` with `api_key_id` instead of `project_id` | | DELETE | `/keys/{id}` | admin | Delete outright → `{"ok": true}`; `409 {"error":{"code":"key_in_use"}}` while request logs still reference the key. Revoking is almost always what you want | Request body: ```json {"kind": "gateway", "name": "ci-pipeline", "scopes": ["chat", "models", "retrieve"], "project_ids": [1, 2], "default_project_id": 1, "expires_at": "2027-01-01T00:00:00Z", "user_id": 4, "rate_limit_rpm": 0, "rate_limit_tpm": 0, "budget_daily_tokens": 0, "budget_monthly_tokens": 0} ``` `kind` is `gateway` (default) or `management`. `name` is required, at most 64 characters and unique per owner (`409` otherwise). A gateway key's `scopes` may name `chat`, `models`, `embeddings`, `retrieve` and `rerank` and defaults to all five when omitted or empty; a management key's `scopes` may name `read`, `write`, `admin` and `keys` and are required. An unknown scope (the message lists the valid values for the kind), or one the caller's own role does not cover, is a `400`. `expires_at` is RFC 3339, and `null` or `""` clears it. `user_id` mints the key for another account and needs the `admin` role; the grants are then checked against *that* account, not the admin's. For a `gateway` key, every id in `project_ids` must be a project the **owner** can access (`400` otherwise) and `default_project_id` must be one of them. For a `management` key, projects and limits are refused with a `400`: it has neither. Creating a `management` key additionally needs at least the `editor` role and a dashboard session (`403 session_required` for an api key); the `admin` scope needs the `admin` role. The key object never contains the credential: ```json {"id": 7, "kind": "gateway", "name": "ci-pipeline", "key_prefix": "sk-user-Wg8TwBF", "user_id": 1, "created_by": 1, "scopes": ["chat", "models", "retrieve"], "project_ids": [1, 2], "default_project_id": null, "rate_limit_rpm": 0, "rate_limit_tpm": 0, "budget_daily_tokens": 0, "budget_monthly_tokens": 0, "expires_at": null, "revoked_at": null, "last_used_at": "2026-09-19T03:41:00Z", "created_at": "…", "updated_at": "…"} ``` `key_prefix` is the first 15 characters, for identification in listings and the audit log. `last_used_at` is stamped by `/v1` at most once a minute per key, so a busy key does not rewrite its row on every request. Semantics, scopes and the interaction of the two limit tiers are described in [Users, roles and limits](/docs/users-and-limits/#api-keys). ### Metrics | Method | Path | Role | Purpose | |---|---|---|---| | GET | `/metrics/summary?window=24h&compare=1&days=14&by_project=1` | viewer | Summary over the projects the caller can see | | GET | `/metrics/requests?limit=50&project_id=3` | viewer | Recent request logs, newest first (`limit` defaults to 100, max 1000; `project_id` must be visible to the caller) | | GET | `/metrics/requests.csv?window=24h&project_id=3` | viewer | Request logs in the window as a CSV download (`project_id` optional, same visibility rule) | `window` is a Go duration (default `24h`). Optional summary parameters: | Parameter | Effect | |---|---| | `compare=1` | adds `previous`: the same summary fields for the window of equal length that ends where the current one starts | | `days=N` | sizes the `daily` series (1–90, default 14; out-of-range values are clamped) | | `by_project=1` | adds `projects`: one row per project with requests in the window, busiest first, limited to the projects the caller can see | The summary response (also used by `/projects/{id}/metrics`): ```json {"window": {"requests": 120, "errors": 3, "prompt_tokens": 51000, "completion_tokens": 9800, "avg_latency_ms": 840.5, "p95_latency_ms": 2100, "rag_requests": 95, "rate_limited": 2, "cost_micros": 184500, "cost_usd": 0.1845}, "total": {"…": "same fields over all time"}, "daily": [{"day": "2026-09-05", "requests": 10, "errors": 0, "prompt_tokens": 4000, "completion_tokens": 900, "cost_micros": 15400, "cost_usd": 0.0154}], "recent": [{"id": 991, "project_id": 3, "model_name": "claude-sonnet-4-5", "status_code": 200, "prompt_tokens": 420, "completion_tokens": 80, "estimated": false, "latency_ms": 910, "streamed": true, "rag_used": true, "rag_hits": 3, "cached_prompt_tokens": 200, "cache_write_tokens": 0, "cost_micros": 1860, "cost_usd": 0.00186, "cost_source": "builtin", "error": "", "api_key_id": 7, "user_id": 4, "request_id": "3f2a9c1e", "endpoint": "chat.completions", "requested_model": "smart", "route": "smart", "attempt": 2, "fallback_from": 1, "created_at": "…"}], "previous": {"…": "only with compare=1"}, "projects": [{"project_id": 3, "name": "support-bot", "requests": 80, "errors": 2, "prompt_tokens": 30000, "completion_tokens": 6000, "rate_limited": 1, "rag_requests": 70, "cost_micros": 120000, "cost_usd": 0.12}]} ``` `daily` covers the last `days` days (14 by default), `recent` the last 50 requests. `rag_hits` is the number of retrieved passages injected into that request (`0` when `rag_used` is false). `api_key_id` and `user_id` attribute the request to the user-owned key that made it and are `null` for a project's default key; the owner is denormalised so deleting the key later leaves `user_id` in place. `request_id`, `endpoint`, `requested_model`, `route`, `attempt` and `fallback_from` identify how the request ran and are left out of a row when empty (always for rows written before migration `0016`); `attempt` is the number of upstream calls, `fallback_from` the connection id of the first target when the request moved to another one, and `route` is absent for the implicit route. Their meaning is in [the request log](/docs/observability/#the-request-log). Status semantics (`429`, `499`, `502`, `503`/`504`) are described in [Users, roles and limits](/docs/users-and-limits/#metrics-and-retention). Cost fields appear on the summary objects (`window`, `total`, `previous`), on each `daily` bucket, on each `projects` row and on each `recent` request: | Field | Meaning | |---|---| | `cost_micros` | the **estimated** cost in USD millionths; an integer, so sums are exact | | `cost_usd` | the same figure in dollars (`cost_micros / 1e6`) | | `cost_source` | on a request: `builtin` or `user` for the price row that matched, `none` when no row did (then the cost is `0`) | | `cached_prompt_tokens` | part of `prompt_tokens` that a provider cache served | | `cache_write_tokens` | part of `prompt_tokens` that was written into a provider cache (Anthropic only) | Cost is an estimate from the [price table](#model-prices), never a bill, and it is informational only — budgets are counted in tokens, not money. #### CSV export `GET /metrics/requests.csv` and `GET /projects/{id}/metrics.csv` return the window's request logs, oldest first and at most 50 000 rows, as `text/csv` with a `Content-Disposition: attachment; filename="ragmux-requests-.csv"` (or `ragmux-project--requests-.csv`) header. Columns: ``` created_at, project_id, project_name, model_name, status_code, prompt_tokens, completion_tokens, estimated, latency_ms, streamed, rag_used, rag_hits, error, cached_prompt_tokens, cache_write_tokens, cost_usd, cost_source, api_key_id, user_id ``` New columns are appended at the end and existing ones never move, so an importer that reads by position keeps working. `cost_usd` is written with six decimals — the full precision of the stored micro-dollar integer, so a cheap request does not round to zero. `api_key_id` and `user_id` are empty for a request made with a project's default key, which has no owner; an empty cell says that where a `0` would read as user zero. Cells that begin with `=`, `+`, `-` or `@` (also after a leading tab or carriage return) are prefixed with a single quote so a spreadsheet does not evaluate them as formulas; model names and error messages can be shaped by an upstream. The CSV carries the columns above only: `request_id`, `endpoint`, `requested_model`, `route`, `attempt` and `fallback_from` are in the JSON log and the database, not in the export. ### Model prices The price table turns the token counts of a finished request into the estimated `cost_micros` on its log row. **It is an estimate, not a bill**: providers round, discount and change prices without telling the gateway. | Method | Path | Role | Purpose | |---|---|---|---| | GET | `/prices` | viewer | `{"prices": [...], "builtin_version": 3, "unit": "per_million_tokens", "search_unit": "per_unit"}` | | POST | `/prices` | editor | `{provider_type, model_pattern, input_per_mtok, output_per_mtok, cache_write_per_mtok?, cache_read_per_mtok?, per_search_unit?, currency?}` → `201 {price}`; always created with `source: "user"`. `409` when that provider/pattern pair already exists | | PUT | `/prices/{id}` | editor | Same body without `provider_type`/`model_pattern` (they are fixed); sets `source: "user"` → `{price}` | | DELETE | `/prices/{id}` | editor | `{"ok": true}`; `409 {"code":"builtin_price"}` for a built-in row — reset it instead | | POST | `/prices/{id}/reset` | editor | Restores the shipped values and marks the row built-in again → `{price}`; `409 {"code":"no_builtin_price"}` when the row has no shipped counterpart | Every mutation is audited as `price.create`, `price.update`, `price.delete` or `price.reset`, and drops the gateway's cached table so the next request is costed with the new numbers (it is otherwise re-read every 60 seconds). A row: ```json {"id": 4, "provider_type": "anthropic", "model_pattern": "claude-sonnet-4-5*", "input_per_mtok": 3.0, "output_per_mtok": 15.0, "cache_write_per_mtok": 3.75, "cache_read_per_mtok": 0.30, "per_search_unit": null, "currency": "USD", "source": "builtin", "builtin_version": 3, "created_at": "…", "updated_at": "…"} ``` `per_search_unit` is the price of one search unit of a rerank API, in the row's currency **per unit, not per million tokens**; `null` means the model has none. Cohere bills a rerank call by search unit (the response's `meta.billed_units.search_units`, taken as 1 when absent), so its rows carry it. Voyage bills rerank by token and uses `input_per_mtok`. The value is 0 to 100. A row with a `cohere_rerank` or `voyage_rerank` `provider_type` prices the rerank connection of that type; a spend whose connection has no matching row adds 0 to `cost_micros`. A row whose `per_search_unit` is set (not `null`, `0` included) prices a rerank by search units alone: its token prices are never added for that call, so a token price typed into a Cohere row has no effect on rerank spend, and an explicit `0` makes the rerank free. Prices are **per million tokens** and prices are between 0 and 100000. The two cache prices are **absolute prices, not multipliers** of `input_per_mtok` — every provider picks its own convention (Anthropic bills writes at 1.25x and reads at 0.1x, OpenAI does not bill writes at all and reads at 0.5x) — and both are nullable: `null` means the provider charges the input rate. **Matching.** A model name is resolved against the rows of its connection's provider type. An exact `model_pattern` wins; otherwise the pattern with the **longest literal prefix** (the text before its first `*`) that matches, ties going to the lowest `id`. `*` stands for any run of characters, `/` included, so `deepseek-ai/DeepSeek-V3*` works. Longest prefix is why `gpt-4o-mini*` beats `gpt-4o*` for `gpt-4o-mini-2024-07-18`. With no match the main call adds 0 and the request is logged with `cost_source: "none"` — a missing price is reported as missing, never guessed. A rerank that ran on another connection inside the same request is priced by its own row and still adds its cost. `source` plays no part in matching. Your own row wins where its pattern is the more specific one, and you override a shipped number by **editing that row** (which keeps its pattern and flips it to `"user"`) rather than by adding a broader one — a `*` row that outranked every built-in pattern would silently re-price every model the shipped table already knows. **Built-in rows and upgrades.** The shipped table is seeded on every start. - Editing a built-in row flips its `source` to `"user"`, and **upgrades never touch a `"user"` row again**. - An untouched built-in row is refreshed only when the shipped table's `version` **increases**; a release that does not bump it changes nothing. - **Built-in rows cannot be deleted**, only edited or reset. That removes the "deleted row resurrects on upgrade" problem without a tombstone column to remember it by. - The seed only inserts and refreshes; it **never removes a row**. Dropping a model from the shipped table therefore leaves existing installs holding the old row, and retiring one is a numbered migration you can read in the release — a general "remove whatever the shipped table stopped listing" rule would let a pattern renamed in a later release silently drop a model's price everywhere. - `ollama` ships a `*` row at 0: it runs on your own hardware, so it must never report phantom spend. **`custom_openai` ships no row at all** — it is a URL, and it points at a paid API as readily as at vLLM, so its models are `cost_source: "none"` until you add a price. A catch-all at 0 would report a real bill as `$0.00` with `cost_source: "builtin"`, which reads as a priced zero rather than the missing price it is. ### Users and audit log | Method | Path | Role | Purpose | |---|---|---|---| | GET | `/users/lite` | editor | Active users as `[{id, username, role}]` (for member pickers) | | GET | `/users` | admin | All users, each with `project_count` (memberships) and `active_sessions` (unexpired sessions) | | POST | `/users` | admin | `{username, password, role, project_ids?}` → `201` user (`role` defaults to `viewer`, password at least 12 characters and ≤ 72 bytes, username ≤ 64); `project_ids` adds the memberships in the same transaction, `422` when one of them is unknown; `409` when the username already exists in any casing | | GET | `/users/{id}` | admin | Read one | | PUT | `/users/{id}` | admin | `{role, is_active}` (both optional); deactivating drops the user's sessions | | DELETE | `/users/{id}` | admin | Delete → `{"ok": true}` | | POST | `/users/{id}/reset-password` | admin | `{new_password}` (at least 12 characters, at most 72 bytes); revokes the user's sessions | | POST | `/users/{id}/sessions/revoke` | admin | Sign the user out everywhere | | GET | `/audit` | admin | Audit entries, newest first, with the total for the filter | | GET | `/audit/export` | admin | The same filters as NDJSON (one entry per line), at most 100 000 rows, newest first, as an attachment; audited as `audit.exported` | | GET | `/security/logins` | admin | Failed-login counters and active lockouts | `GET /users` answers an array of user objects with the two counters: ```json [{"id": 1, "username": "admin", "role": "admin", "is_active": true, "last_login_at": "2026-09-18T10:00:00Z", "created_at": "…", "project_count": 2, "active_sessions": 1}] ``` `PUT`/`DELETE` on users refuse to demote, deactivate or delete the last active admin (`409`) and to deactivate or delete the caller's own account (`400`). `GET /audit?limit=100&action=project.&actor_user_id=1&since=2026-09-01T00:00:00Z&until=2026-09-18T00:00:00Z&before=2026-09-17T10:00:00Z`: `action` is a prefix match, `since` (inclusive) and `until` (exclusive) bound `created_at`, `before` pages backwards (all three RFC 3339; a malformed value is a `400`), `limit` defaults to 100 (max 1000). The response is ```json {"entries": [{"id": 512, "actor_user_id": 1, "actor_username": "admin", "action": "project.rotate_key", "target_type": "project", "target_id": 3, "details": {"name": "support-bot"}, "ip": "203.0.113.7", "created_at": "…"}], "total": 1834, "has_more": true} ``` where `total` counts every entry matching `action`, `actor_user_id`, `since` and `until` (ignoring `before` and `limit`) and `has_more` tells whether another page exists beyond this one. `GET /audit/export?action=login.&since=…` streams the same entries as `application/x-ndjson` (`Content-Disposition: attachment; filename="ragmux-audit-.ndjson"`), one JSON object per line in the shape above, newest first, capped at 100 000 rows; `before` is honoured so a larger range can be exported in slices. Every export writes an `audit.exported` entry whose `details` carry the filters that were used. `GET /security/logins`: ```json {"failed_last_hour": 12, "failed_last_24h": 40, "active_lockouts": [{"username": "admin", "ip": "203.0.113.7", "until": "2026-09-18T10:14:00Z"}]} ``` The counters are failed attempts in the `login_attempts` table; `active_lockouts` lists the username/address pairs the login limiter currently refuses, computed with the same `LOGIN_LOCKOUT_FAILURES` / `LOGIN_LOCKOUT_MINUTES` rule, with `until` the time the lockout ends (empty when the lockout is disabled). ### System `GET /system` (viewer), with sample values — `migrations_version` and `version` are whatever the running build reports: ```json {"database": {"postgres_version": "17.11", "pgvector_version": "0.8.6", "pg_search_version": "", "migrations_version": N, "size_bytes": 8787635}, "backup": {"tables": 1, "documents_bytes": 1048576, "last_migration_at": "2026-09-18T12:34:41Z"}, "secret_key_source": "env", "version": ""} ``` `backup.tables` is the number of `chunk_embeddings_` tables, `documents_bytes` the bytes of uploaded files stored in the database, `secret_key_source` is `env` or `file`. `database.pg_search_version` is the installed ParadeDB `pg_search` extension, empty when it is absent (see [Search backends](/docs/rag/#search-backends)). `database.postgres_version`, `database.pgvector_version` and `database.pg_search_version` are only returned to admins; editors and viewers receive the `database` object without those three keys. ## Health `GET /healthz` pings the database and answers `200 {"status":"ok","version":""}` (the version of the running build) or `503 db unavailable` (plain text). No authentication. ## Client API (`/v1`) Point any OpenAI SDK at `http://:8765/v1` with the project key as `api_key`. ### `POST /v1/chat/completions` Accepts the OpenAI Chat Completions payload: `model`, `messages` (text or `text` / `image_url` content parts), `stream`, `temperature`, `top_p`, `max_tokens` / `max_completion_tokens`, `stop`, `n`, `tools`, `tool_choice`, `response_format`, `stream_options`, `user`. Unknown fields are forwarded to OpenAI-compatible upstreams unchanged (see [Providers](/docs/providers/)), with one exception: an optional `ragmux` object is consumed by the gateway and never sent upstream. `messages` is required; the body may not exceed 4 MiB. ```json {"model": "…", "messages": […], "ragmux": {"include_context": true}} ``` `include_context` asks for the retrieved sources and the exact context block that was injected, added as a top-level `ragmux` object to a non-streaming response (see [RAG](/docs/rag/#sources-on-the-response)); streaming responses only carry the headers. What happens to a request, in order: 1. The project's rate limits and budgets are checked (before any embedding call). 2. The project `system_prompt`, if set, is prepended to the first `system`/`developer` message (or inserted as one). 3. If the project has a RAG store, the last `user` message is used as the query and the retrieved passages are injected the same way; the response carries `x-ragmux-rag-hits: ` and, when passages were injected, `x-ragmux-rag-sources` listing them. Retrieval failures are logged and the request continues without context. 4. The request is translated for the provider and sent. A project without chat [routes](#routes) ignores `model` for selection: the project's connection decides. Once a project has a chat route, `model` selects what runs and an unknown value is `404 model_not_found` (see [`GET /v1/models`](#get-v1models-and-get-v1modelsid)). The value you send is echoed back in the response (`model` of the completion and every stream chunk); an empty value is replaced with the connection's model name. A failure before the first byte can be retried and handed to the route's next target ([Retry and fallback](/docs/providers/#retry-and-fallback)). Non-streaming responses are the provider's answer normalised to the OpenAI schema (`choices[].message`, `finish_reason`, `usage`). Streaming responses are `text/event-stream` with `data: {chunk}` lines and a final `data: [DONE]`; a failure after the stream has started is emitted as a `data: {"error": …}` event before `[DONE]`. **The usage-only trailer** — one final chunk carrying `"choices": []` and `usage` before `[DONE]` — is sent only when you set `stream_options: {"include_usage": true}`, exactly as OpenAI does. This holds on every provider: the gateway always asks its upstream for token counts, because the request log, the budgets and the cost are built from them, but it does not pass that trailer on to a client that did not ask for one, because a client indexing `choices[0]` on every chunk would break on it. That is a promise about Ragmux's own trailer, not about every chunk. An upstream is relayed as it comes, and some send frames of their own with an empty `choices` array — Azure's content-filter chunk, a proxy's keep-alive. Ragmux does not drop those: they carry information it does not own. Guard the array before indexing it. `usage` carries OpenAI's breakdown objects whenever the provider reports them: ```json "usage": {"prompt_tokens": 250, "completion_tokens": 500, "total_tokens": 750, "prompt_tokens_details": {"cached_tokens": 200, "cache_creation_tokens": 40}, "completion_tokens_details": {"reasoning_tokens": 30}} ``` `prompt_tokens` always includes the cached and freshly written parts, on every provider: that is a property of the mapping, so it holds whatever the upstream's own spelling was. `prompt_tokens + completion_tokens == total_tokens` is weaker — it describes how each adapter assembles the block from an upstream that reports its counts consistently, and it is not checked. Two things break it: a Gemini request that called tools, where Gemini's own total can be larger and the difference is neither mapped nor priced (see [Gemini tool-use tokens](/docs/providers/#tool-calling)), and any upstream that reports something that does not add up, which is relayed as it came (below). A client that relies on the sum should verify it. `cache_creation_tokens` has no OpenAI equivalent (OpenAI does not bill cache writes, Anthropic does). See [Prompt caching](/docs/providers/#prompt-caching) for the per-provider mapping, including the accounting change for cached Anthropic requests. The `usage` block in the response is the **upstream's own**. Normalising renames fields into the OpenAI shape and fills two gaps — a `total_tokens` of `0` is computed from the two parts, and DeepSeek's top-level `prompt_cache_hit_tokens` is folded into `cached_tokens` — but it validates nothing. A provider that reports something impossible, a negative count or a total that does not add up, reaches you that way. What Ragmux **records** is cleaned. The request log, the budget counters and the cost estimate floor every count at zero; a `prompt_tokens` or `completion_tokens` that arrived unusable is replaced with a character estimate and the row is flagged `estimated`; an unusable cache split drops to zero without that flag, because the flag speaks for those two counts and they were fine. So a request log row and the `usage` of the same request can differ when the upstream misreported. Trust the row for spend, and treat a mismatch as a signal about that provider. Response headers, only for limits that are set on the project or the key. Where both tiers have a limit, the header describes whichever has the smaller remaining allowance: | Header | Meaning | |---|---| | `x-ratelimit-limit-requests` | configured requests per minute | | `x-ratelimit-remaining-requests` | requests left in the current UTC minute | | `x-ratelimit-reset-requests` | whole seconds until the minute window resets | | `x-ragmux-budget-daily-remaining` | tokens left in today's budget | | `x-ragmux-budget-monthly-remaining` | tokens left in this month's budget | | `x-ragmux-rag-hits` | passages injected (present whenever the project has a store, `0` when none matched) | | `x-ragmux-rag-sources` | JSON array of `{document_id, filename, section, page, score}` for the injected passages; present only when `x-ragmux-rag-hits` is above `0`, trimmed to whole entries to stay under 2 KB | | `X-Request-Id` | on every `/v1` response, errors included. The client's own `X-Request-Id` when it is 1 to 128 characters of `A-Z a-z 0-9 . _ : -`; anything else (absent, empty, longer, other characters) is replaced by an id the gateway generates — never rejected. The value is stored in `request_logs.request_id` for every call that writes a row: one that went upstream or was denied by a limit. A call answered earlier (`401`, `403`, `400`, `project_required`, an unresolvable route) carries the header but has no row. See [the request log](/docs/observability/#the-request-log) | | `x-ragmux-projects` | on a `400 project_required` only: the project names the key grants, comma separated | | `Warning` | `299 ragmux ""` when the connection accepted the request but ignored part of it — today only `response_format` (`json_schema`/`json_object`) on an Anthropic connection. Set before the first byte, so a stream carries it too. See [Anthropic](/docs/providers/#anthropic) | Browser clients need `CORS_ORIGINS` to read any of these: the gateway lists them in `Access-Control-Expose-Headers` (together with `Retry-After` and `X-Request-Id`; `Warning` is in the table above) and accepts `X-Ragmux-Project` in `Access-Control-Allow-Headers`, but a cross-origin page sees no response header outside the CORS safelist unless the request went through CORS at all. Status codes: | Status | When | |---|---| | `400` | malformed JSON, missing `messages`, a translation error such as an image the provider's adapter cannot take (an unsupported type, or a remote URL while `IMAGE_FETCH=false`; see [Images](/docs/providers/#images)), or a multi-project key that named none (`code: "project_required"`, with `X-Ragmux-Projects` listing the valid values) | | `401` | missing or invalid key; `code` is `key_revoked`, `key_expired` or `key_owner_inactive` for a key that exists but no longer resolves, and `null` for an unknown one | | `403` | the key lacks the route's scope (`type: "insufficient_scope"`), or it does not grant the requested project (`code: "project_not_granted"`) | | `404` | the project has chat routes and `model` names neither a route nor the default connection's model (`code: "model_not_found"`) | | `413` | body larger than 4 MiB | | `429` | rate limit or budget exceeded; `Retry-After` set, body `{"error":{"message","type":"rate_limit_exceeded"|"insufficient_quota","code":"rate_limit_rpm"|"rate_limit_tpm"|"budget_daily"|"budget_monthly","scope":"project"|"key"}}` — `scope` says which tier denied | | `429` | the gateway is already fetching as many images at once as `IMAGE_FETCH_MAX_CONCURRENT` allows, or the project has taken its half of them; `Retry-After` set, body `{"error":{"message","type":"rate_limit_exceeded","code":"image_fetch_saturated"}}`. It is a resource limit rather than a fault, so it is not a `5xx`, which would report the gateway as unwell when it is merely full; `Retry-After` carries `IMAGE_FETCH_TIMEOUT`, since a slot frees when a download finishes | | `4xx` from the provider | relayed with the provider's status and message (API-key-looking strings redacted). A `Retry-After` the provider sent (seconds or an HTTP date) is passed through unchanged; a malformed one is dropped | | `503` / `504` | the provider answered `503` or `504` (or, on an Anthropic connection, `529 overloaded`, which becomes `503`): the status is kept, because both say "retry later" rather than "broken", with the provider's message and its `Retry-After`, if any | | `500` | the project's model connection cannot be set up (`model connection unavailable`; the reason is in the gateway log) | | `502` | any other `5xx` from the provider (`500`, `501`, `502`, …, with its message), transport failure, a provider error without a status, or a crash inside the provider adapter during a stream; mid-stream failures are logged as `502` | | `499` | never sent on the wire: recorded in the request log when the client disconnected before the completion finished. The upstream call is cancelled in both the JSON and the streaming path | ### `POST /v1/embeddings` OpenAI's `embeddings.create`, so an SDK's `client.embeddings.create(...)` works against the gateway. Needs the key's `embeddings` scope (`403` otherwise). ```json {"model": "text-embedding-3-small", "input": ["first text", "second text"], "encoding_format": "float"} ``` - `input` is a string or an array of up to 2048 non-empty strings. Arrays of token ids are refused (`400`); the providers behind the gateway embed text. - `encoding_format` is `float` (default) or `base64`, the little-endian `float32` bytes base64-encoded, as OpenAI's SDKs expect. - `dimensions` is not supported and answers `400`: the adapters cannot shorten a vector. ```json {"object": "list", "model": "text-embedding-3-small", "data": [{"object": "embedding", "index": 0, "embedding": [0.01, -0.02, …]}], "usage": {"prompt_tokens": 9, "total_tokens": 9}} ``` `model` is resolved in this order: a route of kind `embeddings` with exactly that name (see [Routes](#routes), which also gives it retries and fallback targets); the implicit route, which is the embedding connection of the project's RAG store and answers to that connection's model name and to an empty `model`; anything else is `404` `{"error": {"type": "invalid_request_error", "code": "model_not_found", …}}`. It never falls back to the project's chat connection, a model that cannot embed, and a project with no RAG store has no implicit route. The response `model` is the client's value (the connection's model name when none was sent). Each call goes through the same steps as chat: limits and budgets are checked first, the call is recorded in the request log with `endpoint = embeddings`, the upstream's `prompt_tokens` and the price. An upstream that reports no usage gets an estimate (characters divided by 4), flagged `estimated` in the log. Errors use the [error envelope](#error-envelope) and the status mapping of chat completions. ### `POST /v1/retrieve` Searches the project's RAG store and returns the passages, without calling a chat model. Needs the key's `retrieve` scope. The scope decides, not the owner's role: a key a viewer minted for the project works. ```json {"query": "how many vacation days do I get?", "top_k": 5} ``` `query` is required. `top_k` is optional, 1 to 50; omitted or `0` uses the store's `top_k`. The store's search mode, backend, rerank and distance settings apply; a request cannot override them (the dashboard's [search endpoint](/docs/rag/#search-endpoint) can). ```json {"object": "retrieval", "mode": "hybrid", "backend": "pg_search", "reranked": false, "rerank_backend": "", "rerank_fallback": false, "retrieval_latency_ms": 41, "rerank_latency_ms": null, "hits": [{"chunk_id": 8, "document_id": 2, "content": "…", "score": 0.0325, "distance": 0.18, "metadata": {"filename": "handbook.pdf", "section": "Leave > Vacation", "page": 12, "chunk_index": 3}}], "usage": {"prompt_tokens": 7, "total_tokens": 7}} ``` `hits` is always an array, empty when nothing matched. `backend` is the one that actually ran. `usage` counts the query's embedding tokens, plus the tokens of the rerank call when the store reranks with the LLM backend (that call goes to the project's chat connection, so a key with only `retrieve` can cause it; it is counted on the request row and against the project's limits, and priced at the rerank connection's rate, not the embedding connection's; an API reranker is priced at its own connection's price, Cohere by search unit). A project without a RAG store answers `400` with code `no_rag_store`. The call is recorded with `endpoint = retrieve`, `rag_used`, `rag_hits`, and the embedding connection's model, tokens and price. An embedding failure at the provider answers with the provider's status as in chat. ### `POST /v1/rerank` Reorders documents by relevance to a query, in Cohere's request and response shape, so a Cohere SDK pointed at Ragmux works. Needs the key's `rerank` scope. ```json {"model": "rerank-v3.5", "query": "vacation days", "top_n": 2, "documents": ["Leave policy: 25 days.", "Parking rules.", {"text": "Sick leave: 10 days."}]} ``` `query` and a non-empty `documents` array (at most 1000; each entry a string or an object with a `text` string) are required. `top_n` is optional and at least `1`; omitted returns every document. `return_documents: true` adds the document text to each result. `model` is optional. ```json {"id": "5c1e…", "results": [{"index": 0, "relevance_score": 0.92}, {"index": 2, "relevance_score": 0.31}], "meta": {"tokens": {"input_tokens": 42, "output_tokens": 0}}} ``` `results` holds at most `top_n` entries sorted by `relevance_score`, highest first; `index` points into the request's `documents`. `model` resolves as for the other endpoints (ADR-018): a `kind = rerank` [route](#routes) with exactly that name; else the implicit route, the project's RAG store's `rerank_connection_id`, when `model` is empty or equals that connection's model name; else `404` with code `model_not_found`. A store without a `rerank_connection_id` has no implicit route. A route target or the implicit connection may be a `cohere_rerank` / `voyage_rerank` connection (their scores are returned as the provider gives them) or a chat connection, which is asked to rank the documents (at most 100 of them). With a chat model, `relevance_score` is derived from the rank, not a model-reported number: the `r` documents the model ranked get `r/r` (`1.0`) down to `1/r`, and the others follow in their request order with a score of `0`. A model that answers with an empty list ("none is relevant") therefore yields every document at `0`, not the client's own order passed off as a ranking. The call is recorded with `endpoint = rerank`: the connection's model, the tokens the provider reported (an estimate from the text, flagged `estimated`, when it reports none), the price, and the usual limits and budgets apply. Rerank tokens spent inside a RAG chat or `/v1/retrieve` request are added to that request's own row instead. An API reranker (`cohere_rerank`, `voyage_rerank`) has a token count that is optional in the response, so the estimate (characters divided by 4, over the passages as sent: each is capped at 4000 characters) is a measure of usage for limits. Its cost follows its price row: Cohere by search unit (a row with a `per_search_unit` never adds token prices), Voyage by token. Inside a chat or retrieve request that cost is added to the row's `cost_micros`, priced with the rerank connection; the `llm` backend's tokens are priced with the chat connection that ran the rerank. A rerank connection with no price row adds 0. ### `GET /v1/models` and `GET /v1/models/{id}` Return the project's models in OpenAI's shape so SDK model listings work: ```json {"object": "list", "data": [{"id": "claude-sonnet-4-5", "object": "model", "created": 1758190800, "owned_by": "anthropic"}]} ``` A project without chat routes lists its connection's model, as above. When the project has chat routes, the list holds each chat route's name (`owned_by: "ragmux"`) plus the implicit route's name, the default connection's model name, unless a route already has that name. With more than one entry, `{id}` has to match one of them (`404` otherwise). When the project has chat routes, `POST /v1/chat/completions` resolves `model` in this order: an exact route name; the default connection's model name (the implicit route); an empty `model` (the implicit route). Anything else is `404` `{"error": {"type": "invalid_request_error", "code": "model_not_found", "message": …}}`. The message repeats the requested value (cut to 128 characters) and does not list the valid names. The response `model` is always the client's value. For a project without chat routes, with one project resolved, `{id}` is ignored and both routes describe that project's connection. A `sk-user-…` key that grants several projects and names none lists one entry per granted project's connection instead, deduplicated by model name; `{id}` then has to match one of them (`404` otherwise). Both routes need the key's `models` scope.