Ragmux
Documentation / Reference

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:

  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

{"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}]}
  • 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):

{"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 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:

[{"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:

  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: <n> 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 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). 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).

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"}
  • 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.
{"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.

Arrow keys to move, Enter to open.