Reference
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.
Updated:
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
{"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 <token>; 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.
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) 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). Usernames are matched
case-insensitively.
Client API
/v1 routes require Authorization: Bearer <key>, 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). 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:
- the
X-Ragmux-Projectrequest header, matched against the granted project names, or against their ids when the value is numeric; - the key’s
default_project_id; - 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
{"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:
{"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).
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 |
| 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 |
GET /provider-types answers an array with one object per type:
[{"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:
[{"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:
{"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; the
two rerank types can serve only rerank targets and RAG store reranking, see
Rerank providers. 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). model_name may contain letters,
digits and . _ : / @ - (up to 128 characters, no leading /, no ..). The response
never contains the key:
{"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:
{"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)):
{"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), 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), 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), rerank_candidates 1-100, max_distance 0-2,
max_documents and max_bytes ≥ 0 (0 = unlimited, see
Unlimited (0)). 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) 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:
{"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
{"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:
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).
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); 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:
{"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).
member_user_ids is only read on create (use the members endpoint afterwards). The
full key is never returned again. The project object:
{"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:
{"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). Adding the first chat route
changes what an unknown model string gets: read
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:
{"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}]}
nameis 1 to 256 characters without control characters and is unique within the project (409otherwise).kindischat(default),embeddingsorrerank.strategyisweighted(default), the only one.targetsholds 1 to 8 entries.connection_idmust exist and its provider type must support the route’skind.modelis optional: when set it replaces the connection’s model name for calls through that target.weightis 1 to 1000 (default 1) andpriorityis 0 to 100 (default 0, lower first). The same connection andmodelpair may appear only once per route.- Invalid input is
400. The response liststargetsordered bypriority, thenid, each withconnection_nameandprovider_typenext to the stored fields.
A route as the API returns it (model is null for a target that keeps the
connection’s model name):
{"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 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:
{"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:
{"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.
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):
{"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. Status semantics (429, 499,
502, 503/504) are described in
Users, roles and limits.
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, 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-<date>.csv" (or
ragmux-project-<id>-requests-<date>.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:
{"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
sourceto"user", and upgrades never touch a"user"row again. - An untouched built-in row is refreshed only when the shipped table’s
versionincreases; 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.
ollamaships a*row at 0: it runs on your own hardware, so it must never report phantom spend.custom_openaiships no row at all — it is a URL, and it points at a paid API as readily as at vLLM, so its models arecost_source: "none"until you add a price. A catch-all at 0 would report a real bill as$0.00withcost_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:
[{"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
{"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-<timestamp>.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:
{"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:
{"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": "<x.y.z>"}
backup.tables is the number of chunk_embeddings_<dims> 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). 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":"<x.y.z>"}
(the version of the running build) or 503 db unavailable (plain text). No
authentication.
Client API (/v1)
Point any OpenAI SDK at http://<host>: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), 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.
{"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); streaming responses only carry the headers.
What happens to a request, in order:
- The project’s rate limits and budgets are checked (before any embedding call).
- The project
system_prompt, if set, is prepended to the firstsystem/developermessage (or inserted as one). - If the project has a RAG store, the last
usermessage is used as the query and the retrieved passages are injected the same way; the response carriesx-ragmux-rag-hits: <n>and, when passages were injected,x-ragmux-rag-sourceslisting them. Retrieval failures are logged and the request continues without context. - The request is translated for the provider and sent. A project without chat
routes ignores
modelfor selection: the project’s connection decides. Once a project has a chat route,modelselects what runs and an unknown value is404 model_not_found(seeGET /v1/models). The value you send is echoed back in the response (modelof 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).
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:
"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), 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 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 |
x-ragmux-projects |
on a 400 project_required only: the project names the key grants, comma separated |
Warning |
299 ragmux "<text>" 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 |
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), 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” |
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).
{"model": "text-embedding-3-small", "input": ["first text", "second text"], "encoding_format": "float"}
inputis 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_formatisfloat(default) orbase64, the little-endianfloat32bytes base64-encoded, as OpenAI’s SDKs expect.dimensionsis not supported and answers400: the adapters cannot shorten a vector.
{"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, 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 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.
{"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 can).
{"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.
{"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.
{"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
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:
{"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.