Ragmux
Documentation / Reference

Reference

Changelog

All notable changes to Ragmux are documented here. The format follows Keep a Changelog and the project uses Semantic Versioning.

Updated:

All notable changes to Ragmux are documented here. The format follows Keep a Changelog and the project uses Semantic Versioning.

Unreleased

0.5.0 — 2026-10-11

Fixed

  • Rerank spend is priced with the connection that did the rerank. The cost_micros of a request row used to be priced by the row’s own connection only: the tokens of a Cohere or Voyage reranker were never priced, llm rerank on /v1/retrieve was priced at the embedding connection’s rate, and in a routed chat a rerank run through the first target was priced at the fallback target that answered. Each rerank spend is now priced by the model_prices row of the connection that ran it and added to the main cost; a spend with no price adds 0 and cost_source stays the main call’s. A price row with a per_search_unit rate (an explicit 0 included) prices a rerank by search units alone; its token prices are never added on top. Token counts and limit accounting are unchanged.

Changed

  • model_prices.per_search_unit (migration 0020, additive) and the price API’s per_search_unit field: the price of one Cohere search unit, per unit rather than per million tokens. The built-in table is version 3 and now prices the Cohere and Voyage rerank models; a built-in row you have not edited is refreshed on upgrade.
  • The span names of the four /v1 endpoints, and gen_ai.operation.name of /v1/retrieve. gateway.chat_completion and its siblings are replaced by the GenAI names listed under Added, so a dashboard or alert that filters on the old names needs updating. The retrieve endpoint’s gen_ai.operation.name is retrieval (the convention’s registered value) instead of retrieve. Metrics and their labels are unchanged.
  • The dashboard search reranks through the store’s rerank_connection_id. The “first project linked to the store” heuristic is gone, so projects sharing a store rerank the same way. A store without that id searches unreranked and the response carries rerank_skipped: true and rerank_skipped_reason. The llm backend may now keep a chat connection in rerank_connection_id (it used to be cleared); existing llm stores need one set to rerank in the dashboard. A skipped search leaves rerank_fallback at false (a skip is not a failure) and no longer counts as a rerank failure in metrics.
  • /v1 accepts the client’s X-Request-Id again. 0.4.x ignored the header entirely. A /v1 request may now send 1 to 128 characters of A-Z a-z 0-9 . _ : -; Ragmux uses it as the response’s X-Request-Id, stores it in request_logs.request_id and logs it as client_req_id. Anything else is replaced by a generated id, never rejected. The span attribute ragmux.request_id, metric labels and the req_id log field stay generated, and /admin/api is unchanged. A value shaped like a credential is accepted, so it can reach your database and logs; see Observability.
  • A project with routes treats model as a selector. In a project that has a chat route, the request’s model has to be a route name, the project’s default model name or empty; anything else is 404 model_not_found instead of being replaced by the connection’s model name and echoed back to the client. GET /v1/models lists the project’s route names and the implicit route. A project without routes behaves exactly as in 0.4.x. See Routes and Providers.

Added

  • OpenTelemetry GenAI span names and per-attempt spans. The request span is now named chat {model}, embeddings {model}, rerank {model} or retrieval {store id} once the request is routed (a request refused earlier keeps gateway.*). Every upstream attempt, retry or fallback, is a gateway.attempt child span carrying ragmux.attempt, ragmux.retry, ragmux.fallback, the route and the failure class; provider spans hang beneath it. The request span adds ragmux.route, ragmux.attempts and ragmux.fallback.from_connection. See Observability.
  • Optional OTLP/gRPC trace exporter. OTEL_EXPORTER_OTLP_PROTOCOL=grpc (or OTEL_EXPORTER_OTLP_TRACES_PROTOCOL) sends traces to a collector’s gRPC port. HTTP/JSON stays the default, and the gRPC client is built on the standard library, so there is no new dependency. OTEL_EXPORTER_OTLP_INSECURE=true makes a scheme-less endpoint plaintext. See Observability.
  • POST /v1/rerank. Cohere-shaped rerank endpoint (query, documents, top_n, model; results[{index, relevance_score}]), behind the new-style pipeline with scope rerank and endpoint = rerank in the request log. model resolves to a kind = rerank route, else to the RAG store’s rerank_connection_id (the implicit route), else 404 model_not_found. A rerank API connection (cohere_rerank, voyage_rerank) or a chat connection can serve it. See POST /v1/rerank.
  • Rerank tokens are counted. The tokens of an LLM or API rerank inside a RAG chat or /v1/retrieve request are added to that request’s row, and a /v1/rerank call logs its own tokens. An API reranker’s tokens count toward limits and usage but are not priced (it bills by search unit); the estimate is made over the passages as sent (capped at 4000 characters each), not the full text. LLM rerank tokens are priced at the row’s model.
  • /v1/rerank through a chat model scores only what the model ranked. The documents the model ranked get 1.0 down to 1/r; the others follow in request order with a score of 0, and an empty answer scores every document 0.
  • RERANK_MAX_TOKENS sets the reply cap of the LLM reranker (default 200, as before).
  • Retry and route-internal fallback. A chat call that fails before the first byte is sent is retried on the same target (connection never established, 429, 503) and then handed to the route’s next target; UPSTREAM_MAX_RETRIES (default 2, 0 disables) sets the retries per target. UPSTREAM_TIMEOUT is now the budget of all attempts together. A request is still one request_logs row, with attempt and fallback_from filled in. New metrics ragmux_upstream_retries_total and ragmux_upstream_fallbacks_total. See Retry and fallback.
  • Circuit breaker per model connection. A connection that fails BREAKER_FAILURE_THRESHOLD times in a row (default 5, 0 disables) is skipped by fallback for BREAKER_OPEN_DURATION (default 30s), then probed with a single call. When every target of a route is open the first one is called once, without retries. State is in memory and per replica. New metric ragmux_upstream_breaker_state{connection} (0 closed, 1 open, 2 half-open). See Retry and fallback.
  • request_logs gains request_id, endpoint, requested_model, route, attempt and fallback_from (migrations 0016, 0017); see the request log. 0017 builds its index CONCURRENTLY, outside a transaction. requested_model is cut to 256 bytes.
  • Routes. A project can hold named routes of 1 to 8 targets, each a model connection with an optional upstream model name, a weight (1-1000) and a priority (0-100). A call tries the targets by priority group, in weighted random order inside a group. Managed through /admin/api/projects/{id}/routes (migration 0019: project_routes, route_targets); audit events route.create, route.update and route.delete. A connection that a route target uses cannot be deleted (409). See Routes and Providers.
  • POST /v1/embeddings. OpenAI-shaped embeddings endpoint (float and base64 encodings) behind scope embeddings, with endpoint = embeddings in the request log. model resolves to a kind = embeddings route, else to the RAG store’s embedding connection, else 404 model_not_found. See POST /v1/embeddings.
  • POST /v1/retrieve. Returns the chunks a RAG store would put in a chat prompt, with scores, distances, metadata, the search mode and backend, rerank information and usage, behind scope retrieve. It runs the store’s own search settings. A viewer member’s key with the scope may call it, because /v1 does not read roles. See POST /v1/retrieve.
  • ragmux_ingest_embed_tokens_total{source}. Embedding tokens spent by ingestion, split into reported (the provider’s count) and estimated (characters divided by 4). The “document ingested” log line carries embed_tokens. Embedding calls of /v1/embeddings and /v1/retrieve are logged in request_logs like any other request.
  • Azure OpenAI. Provider type azure_openai (chat, streaming and embeddings), with the resource endpoint in base_url, the deployment in model_name and an optional api-version query on the base_url (default 2024-10-21). The key is sent as api-key and is covered by rotate-key. See Providers.
  • Provider presets. Mistral, xAI and Groq are offered as custom_openai presets through GET /admin/api/provider-presets. A preset is a template for the form, not a stored object, and carries no price. See Providers.
  • Dashboard. The project page has a route editor, the RAG store form filters the rerank connection by backend, the connection form offers the presets and the Azure fields, and the API key form lists the embeddings, retrieve and rerank scopes. A key edited in the dashboard keeps the scopes it already had.
  • Docs drift test. internal/admin/docs_drift_test.go compares the JSON field names of the admin API with docs/api.md in both directions, so a field added to the code without a line in the docs, or left in the docs after it was removed, fails the build. Fields left out on purpose are listed in the test with a reason each.

Security

  • Existing chat gateway keys gain the embeddings, retrieve and rerank scopes. Migration 0018 adds the three new scopes to every gateway key that carries chat, revoked and expired keys included, so those keys can call the new /v1 endpoints after the upgrade. It only adds scopes and is safe to run twice; keys with only models, and management keys, are untouched. Edit a key’s scopes afterwards to keep it out of the new endpoints. The project’s default sk-proj-… key carries no scopes and reaches them automatically. See Users, roles and limits.

0.4.2 — 2026-10-02

Bug fixes, observability and documentation. No schema change.

Added

  • Gemini tool calls keep their thoughtSignature. Gemini 3 thinking models expect the signature on a tool-calling turn to come back with the next request, and a request that dropped it could be answered with 400. The signature now travels to the client as an opaque tool_calls[].ragmux_signature field (streamed and non-streamed alike) and is written back onto the functionCall part when the client sends the turn again. It is stripped for every non-Gemini upstream, including a fallback, so it never reaches OpenAI, Anthropic or Ollama. A client that rebuilds tool calls by hand has to copy the field; see Providers.
  • Gemini response_format: json_schema is forwarded as generationConfig.responseSchema after the schema is reduced to the subset Gemini accepts. Dropped keys are logged at debug level; a schema that is left with no properties falls back to JSON mode alone.
  • Upstream Retry-After is passed on. A delay in seconds or an HTTP date reaches the client on non-streaming errors and on errors before a stream starts; a malformed value is dropped.
  • Anthropic connections no longer drop response_format silently. json_schema and json_object are still ignored, but the response now carries Warning: 299 ragmux "response_format is not supported for anthropic connections; ignored" (streams included) and the event is logged at info level. Warning is listed in Access-Control-Expose-Headers so browser clients can read it. Full support is planned for v0.7.
  • GenAI span attributes. Chat completion spans carry gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, gen_ai.response.model, gen_ai.response.finish_reasons and gen_ai.usage.cache_read.input_tokens; provider and RAG embedding spans carry gen_ai.provider.name. No prompt or completion text is exported.
  • New metrics: ragmux_requests_unpriced_total{provider} (successful completions with no matching price row), ragmux_budget_used_ratio{project} (the share of the tightest token budget used, inside the series cap), ragmux_retention_last_success_timestamp_seconds and ragmux_rag_index_missing (RAG stores whose vector index could not be built).
  • deploy/prometheus/alerts.yaml: alerting rules for availability, error rates, ingestion backlog, dropped metric series, budget exhaustion and retention lag, checked with promtool in CI.
  • otel-collector.yaml ships commented exporter examples for Langfuse and Arize Phoenix.

Changed

  • Upstream 503 and 504 are relayed as themselves instead of being turned into 502, so a client can tell “overloaded or slow, retry later” from “broken”. Every other upstream 5xx is still 502. Anthropic’s 529 overloaded is relayed as 503, with the upstream’s Retry-After.
  • Anthropic stop_reason mapping. refusal now maps to finish_reason: "content_filter" and model_context_window_exceeded to length. Any other unknown value maps to stop and is logged at warn level. These used to be relayed verbatim, outside the set OpenAI clients switch on.
  • RAG stores refuse embedding models wider than 2000 dimensions — pgvector’s HNSW limit for the vector type — both at the first ingest and when a store is created or edited in the admin (400). A store that is already wider keeps working, without an index; it is logged at startup and counted by ragmux_rag_index_missing. halfvec support is planned for v0.6.
  • A fts_config other than simple logs a warning at startup and on save: the stored full-text column is always built with simple, so the setting only changes how queries are parsed until a per-store indexing language lands in v0.6.
  • One password rule everywhere a password is set: at least 12 characters and at most 72 bytes. User creation, password reset and password change in the dashboard and the admin API previously accepted 8 characters, and the dashboard forms now enforce the same minimum. Existing passwords are not re-checked.
  • A non-compliant ADMIN_PASSWORD stops the start when the users table is empty; when users already exist it only logs a warning.
  • Retention deletes run in batches of 5000 rows per statement, 50 ms apart, and stop cleanly on shutdown. The retention log line reports the total rows deleted and the pass duration.

Deprecated

  • The gen_ai.system span attribute is superseded by gen_ai.provider.name. Both are emitted in this release so a dashboard can move over; gen_ai.system is removed in a later release.

Fixed

  • RAG context injection no longer flattens a system message sent as content parts. The parts and their extra fields are kept and the context becomes a new leading part. A project system_prompt keeps the client’s cache_control; when retrieved passages are injected, cache_control on that system message is deliberately dropped, because the per-query prefix could never be read from Anthropic’s prompt cache (placement fix planned for v0.6).
  • Changing a RAG store’s embedding connection resets its dimension, and an ingest that races the change with the old model now fails instead of writing vectors of the wrong size.
  • Documentation that had drifted from the code. The /healthz example no longer shows a fixed 0.2.0; the RAG store and search fields in the API reference match the admin API; the claim that unknown fields are “passed through” to the upstream is corrected; the system message injection order and the point where max_distance applies are stated the same way on every page; 0 meaning “unlimited” is explained in one place; and docs/observability.md no longer lists the tracing export metrics as gauges (they are counters).

0.4.1 — 2026-09-19

Documentation and release plumbing only. No Go code changed since 0.4.0, so the v0.4.1 images carry the same gateway binary as v0.4.0; nothing about the runtime behaviour, the API or the database schema is different. Upgrading is optional.

Added

  • The release workflow publishes the Docker Hub repository page after every image is pushed, from the new docker/README.dockerhub.md. It is a dedicated file rather than README.md because its links have to be absolute — Docker Hub cannot resolve repository-relative ones — and it runs last so the page never describes a release that is not published yet.

Changed

  • The README quick start leads with docker run. It previously opened with cp .env.example .env and docker compose up -d, which silently assumed you had already cloned the repository. The first command now pulls ragmux/ragmux:latest and needs nothing on disk; Compose follows as the second way in, with the git clone step spelled out and a table of the five Compose files and what each one runs.
  • A “Which image” section in the README compares the three published variants (:latest, :latest-app, :latest-paradedb) and says which installation each suits. All three were already being built and signed — only the explanation was missing.
  • Configuration names Docker Hub as the primary registry, with ghcr.io/ragmux/ragmux kept as a mirror carrying identical tags. The deployment-layout table had been naming the GHCR images while the repository’s own Compose files pull ragmux/ragmux. The published-images list also gained the -paradedb variant and a heading of its own.
  • The note on the PostgreSQL major version inside the all-in-one image no longer ties the promise to the 0.3 line: it is 17, it never moves within a minor series, and a release that does move it will say so and name the migration path.

Fixed

  • The SECRET_KEY cross-reference pointed at scaling.md#secret-key, which does not resolve — the heading’s anchor is #secret_key. Corrected in Configuration and Backup and restore.

0.4.0 — 2026-09-19

Added

  • User-owned API keys (api_keys, migration 0010): a key now belongs to a dashboard user and comes in two kinds. A gateway key (sk-user-…) calls /v1 on the projects it is granted; a management key (sk-mgmt-…) calls /admin/api within its scopes, replacing the 24-hour session token scripts had to borrow. Both carry an optional expires_at, can be revoked, record last_used_at, and may set their own rate limits and budgets under the project’s. A key never outranks its owner: on /admin/api its effective permission is its scopes intersected with the owner’s live role, so demoting a user narrows every sk-mgmt-… key they hold at once. A demotion changes nothing about an sk-user-… key — /v1 does not consult roles, and chat and models are covered by every role including viewer — so what stops one of those is revoking it, giving it a smaller sub-limit, or deactivating the owner, which stops every key they hold. A project’s own sk-proj-… key is unchanged and keeps working exactly as before. Managed from the new Keys tab and /admin/api/keys, documented in Users, roles and limits.
  • X-Ragmux-Project: a gateway key granting several projects selects one per request with this header, falling back to the key’s default project and then to its single grant. There is deliberately no "project/model" convention — model names legitimately contain slashes, so the split would be ambiguous and would silently reroute existing clients.
  • ragmux reset-password <username>: resets a dashboard password straight in the database when the last administrator is locked out. Needs DATABASE_URL and no running gateway, takes the new password from --generate or --stdin (never a flag, which would leak into ps and shell history), and writes an audit row attributed to cli, so a console reset appears in the dashboard next to the ones done through the UI.
  • A three-step first-run wizard: the administrator form is now followed by an optional first model connection and an optional first project with its key, so a fresh install does not open on an empty dashboard. Steps two and three call the ordinary authenticated endpoints and add no unauthenticated surface.
  • Gemini tool calling. tools and tool_choice are translated to functionDeclarations and toolConfig, role:"tool" messages become functionResponse parts (correlated by name, which is all Gemini offers), and function calls are returned as OpenAI tool_calls in both the blocking and streaming paths. Tool schemas are rewritten into the OpenAPI subset Gemini accepts: unknown keywords are dropped, const, oneOf/allOf and nullable unions are rewritten, and local $refs are inlined. It is lossy by design — forwarding a schema unchanged fails on essentially every OpenAI strict-mode tool, and the relayed error tells an SDK author nothing. The exact whitelist is in Providers.
  • Remote images for Ollama and Gemini: an image_url the upstream cannot fetch is now downloaded and inlined by the gateway over the same hardened client provider calls use, so the SSRF dialer and redirect policy apply to image hosts too. Bounded by IMAGE_FETCH_MAX_MB, IMAGE_FETCH_TIMEOUT and IMAGE_FETCH_MAX_PER_REQUEST, cached in memory, and switched off entirely with IMAGE_FETCH=false.
  • Prompt caching passthrough: Anthropic cache_control is carried through on content parts, tools and the system block, and every provider’s cache counters are reported back in one shape — usage.prompt_tokens_details with cached_tokens and cache_creation_tokens. OpenAI’s caching is automatic and has nothing to send; only its usage is surfaced.
  • Cost estimation (model_prices, migration 0011): an embedded price table, seeded into the database and editable from the new Prices tab, turns a finished request’s token counts into cost_micros on its log row, pricing cached reads and cache writes at their own rates. Editing a shipped row makes it yours and upgrades never overwrite it again. Summaries, the daily series, the by-project table and the CSV export all carry the figure. It is an estimate, not a bill.
  • custom_openai ships without a price row. It is a URL, and it points at a paid API as readily as at a local vLLM, so its models report cost_source: "none" until you add a row (Prices tab, or POST /admin/api/prices). A catch-all at 0 would report a real bill as $0.00 with cost_source: "builtin" — a priced zero reads as authoritative where a missing price reads as missing. ollama keeps its free catch-all: it runs on your own hardware. The shipped table drops the row as of version 2, so a fresh install never gets it; migration 0015 deletes it from a database seeded by a pre-release build off the v0.4 branch, limited to that pattern and to rows still marked builtin, so a row an operator edited is user, keeps its price and is left alone. The seed itself still only inserts and refreshes — retiring a row stays a numbered migration rather than something an upgrade decides on its own.
  • A ParadeDB search backend: a RAG store can set search_backend: "pg_search" and get real BM25 ranking for the lexical half of a hybrid search instead of Postgres full-text ranking, fused with the vector half by the same reciprocal-rank formula. Switching needs no reprocessing — the lexical side is derived from the stored chunk text — and the index is built lazily on first use. A server without the extension falls back to the existing backend with a warning rather than failing. Ships as docker-compose.paradedb.yml and a :<version>-paradedb image.
  • PG_SEARCH_TOKENIZER: the analyser for that BM25 index, instance-wide because the index is global. default splits on unicode word boundaries and lowercases, matching the tsvector backend, so a store can move between the two without changing which words match. <iso-639-1>_stem adds a Snowball stemmer for twenty languages (ar cs da de el en es fi fr hu it nl no pl pt ro ru sv ta tr); a code outside that set is refused at startup rather than leaving the store on the pgvector fallback at runtime. Pointing the setting somewhere new once the index exists keeps the old analyser — the DDL is CREATE INDEX IF NOT EXISTS — but that is reported as a mismatch naming both the analyser the index carries and the one the configuration asks for, and bm25 index ready logs the analyser queries actually use rather than the configured one.
  • Cohere and Voyage rerankers: rerank_backend selects the existing LLM reranker, cohere or voyage. Their credentials live in a model_connections row like every other provider secret, so they are encrypted at rest and covered by ragmux rotate-key. Any failure still falls back to the fused order instead of failing the request.
  • Leased, cross-replica ingestion (migration 0013): a document is claimed with FOR UPDATE SKIP LOCKED and held under a heartbeat lease, so several replicas share one queue and a replica that dies hands its work back when the lease expires. Replaces the in-process queue that could not be shared.
  • A leader lock for the retention pass, so one replica does the hourly deletions instead of all of them.
  • docs/scaling.md and docker-compose.scale.yml: how to run several replicas, what is already shared, and what must be set first.
  • A Prometheus /metrics endpoint and OpenTelemetry traces, both written against the standard library so the dependency list is unchanged. /metrics is off by default and, when enabled, requires a bearer token or a loopback-only listener — a configuration that would publish it unauthenticated on a public bind refuses to start. The metric set names every project and model and reports per-project token counts and spend, which is the same data the admin metrics endpoints already require a session for. Labels are bounded by construction: the HTTP route is the chi pattern rather than the path, and the model is the connection’s, never the client’s. Tracing is off unless an OTLP endpoint is set, samples at 5% by default, ignores an inbound traceparent unless told to trust it, and covers seven spans; point it at an OpenTelemetry Collector, which is the supported configuration. See Observability.
  • GET /readyz: ready when the pool answers and the schema has reached the version this binary embeds. /healthz stays liveness-only, so container healthchecks are unchanged. The schema comparison is one-directional on purpose: a schema behind the binary answers 503 (migrating), a schema ahead of it answers 200 with a degraded entry. Failing readiness on a newer schema would empty the fleet in the middle of a maxSurge rolling upgrade — the first new pod migrates the shared database and every old replica would take itself out of rotation at once — and would leave a rollback permanently unready. The migrations are additive, so the older code keeps serving. A store whose search backend is unavailable is likewise reported as degraded with a 200, not a 503 — the fallback works, and taking the replica out of rotation would turn a degraded-but-serving instance into an outage.
  • GET /admin/api/search-backends reports which backends this server can actually run. /admin/api/provider-types now carries a full capabilities object taken from the provider package, so it cannot drift from what the adapters do.
  • IMAGE_FETCH_MAX_CONCURRENT (default 16): image fetches the process runs at once, and the image transport’s per-host connection ceiling. IMAGE_FETCH_MAX_PER_REQUEST bounds one request and nothing across them, so without this a key holder could aim the gateway’s own address at a host of their choosing. While another project is queueing, no single one takes more than half the slots, so a tenant pointed at a slow image host cannot starve the rest. Takes, not holds: a slot already taken is not reclaimed, so a tenant can still be above half while those downloads finish. With nobody else waiting, one project reaches the full value. A fetch that cannot get a slot answers 429 with Retry-After and code: "image_fetch_saturated" instead of holding the request: queueing has its own budget, so a wait is never charged to the image host as a download timeout, and Retry-After names IMAGE_FETCH_TIMEOUT, because a slot frees when a download finishes.
  • IMAGE_CACHE_MAX_MB: byte ceiling for the fetched-image cache. Unset, it follows IMAGE_FETCH_MAX_MB — 64 MiB, or enough for two images of the largest size accepted, capped at 256 MiB. A value too small to hold one encoded image is refused at startup, and a derived ceiling that ends up below one is warned about, because such a cache silently stores nothing.

Changed

  • A wrong SECRET_KEY is now a boot failure rather than a per-request error. The database holds a canary only the right key can open, checked before any stored credential is read. This catches a dump restored onto a new host, a key rotated on one node only, a wiped data volume, and a second replica falling back to its own key file — each of which previously surfaced as decrypt errors nobody could trace back. ragmux rotate-key re-seals the canary in the same transaction.
  • Anthropic prompt_tokens now includes cached and freshly written tokens. 0.3.x omitted them, under-reporting a cached request. Token counts, budgets and metrics move up for those requests, and historical rows cannot be corrected.
  • A 503 on document upload now means the cluster’s backlog is full, not that this replica’s in-process queue is — genuine cross-replica backpressure, bounded by MAX_PENDING_DOCUMENTS.
  • The CSV export gains six columns at the end — cached_prompt_tokens, cache_write_tokens, cost_usd, cost_source, api_key_id, user_id. Existing columns keep their positions. The two attribution columns are empty, not zero, for a request made with a project’s default key, which has no owner.
  • /admin/api/provider-types reports supports_tools: true for gemini, and supports_embeddings: false for deepseek. The second is not a new restriction — the dashboard has always shown it — but SupportsEmbeddings disagreed, so a RAG store could be pointed at a DeepSeek connection through the API. Editing such a store now fails validation; change its embedding connection.
  • A Gemini image_url that is not a Files API or gs:// URI is inlined by the gateway instead of being forwarded as fileData, which Gemini rejected.
  • Content parts without a type are treated as text on every provider, matching OpenAI.
  • Ingester.Resume is gone. Unfinished work is found by the lease-based claim on every poll, on every replica, so there is nothing left to resume at start.
  • The request id is generated, never taken from X-Request-Id. Ragmux previously used chi’s middleware.RequestID, which starts from the client’s X-Request-Id header and only generates an id when it is absent. That id reaches the ragmux.request_id span attribute exported to your collector, the X-Request-Id response header and the request log, so a client chose all three. Filtering the header by shape was considered and does not work: a Ragmux gateway key, an AWS key and a bare hex token are the same shape as a generated id, so the header is no longer read at all. If a proxy in front of Ragmux generates X-Request-Id and you correlate on it, that correlation stops here: Ragmux now answers with its own id. Configure the proxy to emit traceparent and set TRACING_TRUST_INCOMING=true to join the two sides in your tracing backend — see the trust gate for what that opens up. Note this recovers trace correlation, not log correlation: Ragmux log lines carry req_id, not a trace id, so a proxy log line and a Ragmux log line still cannot be matched on a shared field. Proxies that mint X-Request-Id (nginx $request_id, HAProxy unique-id) do not emit traceparent on their own.
  • The streaming usage trailer follows include_usage on every provider. The final usage-only chunk ("choices": [] with usage) is now sent only to a client that set stream_options: {"include_usage": true}, the way OpenAI behaves. Previously anthropic, gemini and ollama always sent it, and the OpenAI-compatible path relayed the one it requests upstream, so clients that index chunk.choices[0] on every chunk could break. If you read token counts off a stream, set include_usage; the request log, budgets and cost estimate are unaffected either way, because the gateway still asks its upstream for the counts.
  • Inline data: images are validated before anything is sent upstream, for the gemini, anthropic and ollama connections. The URL must carry ;base64, its media type must be one the connection’s upstream accepts, and the payload must decode as standard base64 (padding included) and not be empty. Each failure is the gateway’s own 400 naming what is wrong; these used to travel to the provider and come back as an upstream 400 that named nothing. The OpenAI-compatible types relay the body verbatim, as before, so their images are still read by the upstream.
  • Image media types are per connection. gemini accepts png, jpeg, webp, heic and heif; anthropic and ollama accept png, jpeg, gif and webp.
  • A redirect that drops from https to http is refused, on the same host as well as across hosts: same host is not the same connection, and the retry would put the Authorization header on the wire in the clear.
  • An inline data: URL is recognised whatever the case of its scheme. DATA:image/png used to be forwarded as a remote URL.

Fixed

  • Ollama tool calls spread over several streaming lines were all given index 0 and merged by clients into one corrupt call; they are now numbered across the whole stream.
  • Browser clients could not read x-ratelimit-*, x-ragmux-* or Retry-After: the responses never carried Access-Control-Expose-Headers.
  • A document whose file type is unsupported no longer has the rejected extension quoted into its error; the message names the supported types instead, so a piece of a user-supplied filename no longer travels into the error or onto the ingest.document span.

Security

  • A management key is accepted only from the Authorization header. One pasted into the session cookie is refused before it is even looked up, which is what keeps the existing CSRF exemption for bearer requests sound: a browser cannot attach an Authorization header cross-origin without a preflight, while it will attach a cookie to any cross-site request on its own.
  • Changing a password, resetting another user’s password and minting a management key require an interactive session, so a leaked key cannot take over the account it belongs to.
  • An expired, revoked or deactivated-owner key answers 401 with a code explaining which; an unknown key keeps the previous opaque message, so nothing can be enumerated by a caller who does not already hold a valid key.
  • GET /admin/api/setup stops describing the installation once setup is done. The endpoint is unauthenticated by design, and it used to answer in full whether or not setup was still pending, handing an anonymous request the migrations version, the PostgreSQL role, where SECRET_KEY came from and — since the wizard landed — has_connections and has_projects. No one field is an opening; together they are a free reconnaissance call on an internet-facing install. Once any user exists the response is {"needs_setup": false} and nothing else. While setup is still pending nothing changes: the wizard’s first screen gets everything it shows. The dashboard now resumes a reloaded wizard from its own stored step instead of from this endpoint.

Upgrade notes

  • Moving an existing store to search_backend: "pg_search" builds the BM25 index inside a request. The first hybrid search of such a store indexes every row in chunks before it answers. The build takes a SHARE lock, so ingest writes to chunks block until it finishes, and it is not CONCURRENTLY: if the client gives up, the build is rolled back and the next request starts over. On a corpus carried over from 0.3.x that is minutes. Build it ahead of the switch instead, without blocking writes:

    CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chunks_bm25 ON chunks
    USING bm25 (id, content, rag_store_id, document_id)
    WITH (key_field = 'id', text_fields = '{"content":{"tokenizer":{"type":"default","stemmer":"English"},"record":"position"}}',
          numeric_fields = '{"rag_store_id":{"fast":true},"document_id":{"fast":true}}');

    Substitute the Snowball language for the code you run (cs → Czech, tr → Turkish, …), or {"type":"default"} with no stemmer for the default analyser — whichever matches PG_SEARCH_TOKENIZER.

    CREATE INDEX CONCURRENTLY cannot run inside a transaction block, so send it on its own — not through psql -1, and not from a migration tool that wraps each step in one.

    Copy the statement exactly. The gateway only ever checks the analyser: it compares text_fields.content.tokenizer against PG_SEARCH_TOKENIZER and warns when they differ. Nothing compares key_field or numeric_fields, so an index built here without numeric_fields is accepted in silence, answers queries, and keeps rag_store_id off the fast-field path for good — CREATE INDEX IF NOT EXISTS will never replace it. The check is deliberately that narrow: it exists to explain a setting that looks applied and is not, and comparing whole reloptions would warn on cosmetic JSON differences a future ParadeDB might render. Verify instead, and drop and rebuild if it does not match:

    SELECT unnest(reloptions) FROM pg_class WHERE relname = 'idx_chunks_bm25';
  • A dump from a ParadeDB server needs a TOC filter to restore onto a plain pgvector one. Dropping idx_chunks_bm25 first is not enough; see Backup and restore.

0.3.1 — 2026-09-18

Added

  • All-in-one container image (Dockerfile.aio, published as ghcr.io/ragmux/ragmux:<version> and :latest): PostgreSQL 17 with pgvector and the gateway in one container, supervised by docker/aio/entrypoint.sh. One volume at /data holds the database (/data/pg) and the secret.key fallback (/data/ragmux); Postgres listens on a unix socket only (no TCP, trust authentication inside the container) and the gateway connects as the least-privilege role ragmux_app. SIGTERM stops the gateway first, then Postgres (pg_ctl stop -m fast); if either process dies the container exits non-zero. DATABASE_URL (or EMBEDDED_POSTGRES=false) skips the embedded server; PG_SHARED_BUFFERS tunes it; ragmux-aio postgres-only runs Postgres alone for maintenance. Documented in Deployment layouts. Superseded by 0.4.1: Docker Hub (ragmux/ragmux) is the primary registry; the ghcr.io names above remain as a mirror with identical tags.
  • scripts/backup.sh and scripts/restore.sh detect the Compose layout (LAYOUT=auto|split|aio): in the all-in-one layout they run pg_dump/pg_restore inside the ragmux container as postgres over the socket, and the restore goes through a one-off postgres-only container while the service is stopped.
  • The backup Compose profile works with the all-in-one file over a shared socket volume.
  • make docker-build-app builds the gateway-only image; make docker-build now builds the all-in-one image.

Changed

  • Breaking for Compose users: docker-compose.yml is now the single-container layout (ragmux service built from Dockerfile.aio, volume ragmux-data, nothing required in .env; SECRET_KEY recommended). The previous two-service file (gateway image + separate pgvector/pgvector:pg17, requiring SECRET_KEY, POSTGRES_PASSWORD and RAGMUX_DB_PASSWORD) is unchanged in behaviour but renamed to docker-compose.split.yml (its local image tag becomes ragmux/ragmux:latest-app, matching the published -app tag): existing deployments add -f docker-compose.split.yml (or COMPOSE_FILE in .env) to keep their pgdata volume, or move to the default layout with a dump/restore cycle (Moving between layouts). No automatic migration of the data volume.
  • Image tags. ghcr.io/ragmux/ragmux:<version>, :<major>.<minor> and :latest now point at the all-in-one image; the gateway-only distroless image is published as :<version>-app, :<major>.<minor>-app and :latest-app. Both are built for linux/amd64 and linux/arm64 and signed with cosign by digest; CI builds both Dockerfiles.
  • docker/postgres-init/01-ragmux.sql is shared by both layouts: the ragmux_app password is only set when the pw psql variable is passed (the split init script still passes it).
  • .env.example lists only optional variables for the default file; the split-layout passwords sit in a separate block at the end.
  • Default port is now 8765 (was 8080): the binary, the Docker image, the Compose file, the health check and the documentation all use it. Set PORT=8080 to keep the old value; existing Compose deployments should update their published port mapping.

Security

  • Signed container images. The release workflow signs the pushed image digests of ghcr.io/ragmux/ragmux (all-in-one and -app, and the Docker Hub mirror when configured) with cosign, keyless through GitHub OIDC. Verify with cosign verify as documented in SECURITY.md; images from v0.3.1 onward carry a signature.
  • Every GitHub Action used by CI and the release workflow is pinned to a full commit SHA (with the release tag as a comment for Dependabot) instead of a floating major tag.
  • Workflow permissions start at contents: read; the release job alone gains contents: write, packages: write and id-token: write. CI cancels superseded runs of the same ref.
  • PDF parsing is isolated and bounded. Each PDF is parsed by a disposable child process (ragmux pdf-extract, the gateway’s own binary) that is killed at the 60 s deadline, so a crafted file that makes the parser recurse without end (a self-referencing object stream, previously a fatal stack overflow that took the whole gateway down) or loop forever (an /Extends or xref /Prev cycle, previously a goroutine spinning past the deadline) now only fails that document. The page tree is walked by the gateway with a depth and node budget instead of the library’s unguarded walk, so /Kids and /Parent cycles end at once and page_count reports the pages found rather than the declared /Count; a page yielding more than 2 MiB of text is rejected. A fuzz target (FuzzExtractPDF) and regression cases for truncated, cyclic, zero-page and oversized inputs cover the parser.

0.3.0 — 2026-09-18

Added

  • RAG sources on chat responses. Responses whose prompt received retrieved context carry x-ragmux-rag-sources, a JSON array of {document_id, filename, section, page, score} for the injected passages, trimmed to whole entries under 2 KB. A request may add "ragmux": {"include_context": true} to receive the same list plus the exact injected context block as a top-level ragmux object on non-streaming responses; the field is stripped before the upstream call and streaming responses only get the header.
  • rag_hits in request logs (migration 0007): the number of passages injected per request, next to rag_used, in /admin/api/metrics/requests and the summary’s recent.
  • Metrics summary options. GET /admin/api/metrics/summary (and /projects/{id}/metrics) accept compare=1 for a previous block covering the window before the current one, days=N (1–90) to size the daily series and by_project=1 for a per-project projects breakdown under the caller’s membership scope.
  • CSV export. GET /admin/api/metrics/requests.csv?window=&project_id= and GET /admin/api/projects/{id}/metrics.csv?window= download up to 50 000 request logs of the window as text/csv with a dated attachment filename; cells starting with =, +, - or @ are prefixed with a quote so spreadsheets do not evaluate them.
  • Budget forecast. GET /admin/api/projects/{id}/usage gains forecast.daily_exhausted_at / monthly_exhausted_at: a linear projection from the tokens consumed in the last 60 minutes, null without a budget, without recent usage, or when the budget outlasts its window.
  • POST /admin/api/models/test tests a connection before it is saved, with the same validation, private-address check and credential redaction as create; connection_id with an empty api_key reuses the stored key of an existing connection.
  • Connection test outcomes are recorded: POST /models/{id}/test writes last_test_at, last_test_ok, last_test_latency_ms and last_test_error (redacted, at most 512 characters), returned by the list and get endpoints (migration 0009).
  • Connections report private_upstream: whether base_url points at localhost, a loopback, private or link-local address (hostnames are classified when the connection is saved and cached in upstream_private; the provider default URL counts as public).
  • GET /admin/api/provider-types reports supports_tools (false for gemini) and supports_streaming.
  • Documents carry page_count (PDF pages, null for other formats) and progress_percent (10 after parsing, 20 after chunking, then per embedding batch up to 100 when ready; a failed document keeps its last value).
  • POST /admin/api/rag-stores/{id}/search returns retrieval_latency_ms and rerank_latency_ms (null when reranking did not run) next to latency_ms.
  • GET /admin/api/audit takes since and until (RFC 3339) next to action, actor_user_id, before and limit, and answers {"entries": […], "total": N, "has_more": bool} where total counts every entry matching the filters. The dashboard’s Audit tab reads the new shape.
  • GET /admin/api/audit/export (admin) streams the same entries as NDJSON, newest first, capped at 100 000 rows, as an attachment; every export is audited as audit.exported with the filters in details.
  • GET /admin/api/security/logins (admin): failed logins in the last hour and 24 hours plus the username/address pairs the login limiter currently locks out, computed with the limiter’s own rule.
  • Failed logins (401) carry attempts_remaining (per-user budget minus recorded failures, identical for unknown usernames and wrong passwords); 429 answers gain "locked": true|false next to Retry-After.
  • GET /admin/api/me reports session_expires_at and session_bearer.
  • GET /admin/api/users entries carry project_count and active_sessions; POST /admin/api/users accepts project_ids and writes the memberships in the same transaction (422 for an unknown project).
  • GET /admin/api/setup reports migrations_version, secret_key_source and database_role so the setup page can confirm which database the gateway runs on.

Changed

  • Dashboard. /admin/ is rebuilt as a sidebar console (dark ground, amber accent, self-hosted Bricolage Grotesque / IBM Plex Sans / JetBrains Mono under web/fonts/, SIL OFL) with forms in side drawers, and wires every endpoint above: window tabs with previous-period deltas, traffic by project, budget watch and forecasts, CSV / NDJSON exports, connection tests before saving, capability and private-upstream pills, quota meters and ingestion progress, per-stage retrieval latencies, usage rings with reset countdowns, playground citations and response headers, user filters and password strength, audit filters with totals, login attempt and lockout feedback, and the setup page’s database and key status. Role-based visibility is unchanged; the page still consists of one inline script pinned by the CSP hash and loads nothing from third parties.
  • Usernames are case-insensitive. Login matches lower(username), and creating a user or the first administrator with a name that differs from an existing one only by case answers 409; the stored casing is preserved. Migration 0008 adds a unique index on lower(username) and refuses to run (with the colliding names) over data that already collides; see Case-insensitive usernames for the manual fix.
  • GET /admin/api/system returns database.postgres_version and database.pgvector_version to admins only; editors and viewers receive the database object without those two keys.

0.2.3 — 2026-09-18

Changed

  • First-run setup replaces the generated password. A fresh database no longer gets an admin account with a random password printed to the logs. The dashboard now shows a “Create the first administrator” form (username, password of at least 12 characters) backed by GET/POST /admin/api/setup, which only work while the users table is empty and refuse with 409 afterwards; the creation is serialised so two concurrent requests cannot both succeed, failed attempts count against the per-address login limit and the result is audited as setup.complete. ADMIN_USER / ADMIN_PASSWORD still pre-create the account for unattended installs; without ADMIN_PASSWORD the log says no users yet: open /admin/ to create the first administrator.

Added

  • SECURITY.md: supported versions, how to report a vulnerability, response targets, scope and hardening pointers.

Security

  • Provider keys are bound to their connection. api_key_enc is now sealed with the connection id as AES-GCM associated data (key_version = 1), so a ciphertext copied onto another row by someone with database write access no longer decrypts there. Rows written by earlier releases (key_version = 0) are re-sealed once on the next start with the same SECRET_KEY; ragmux rotate-key also writes the bound form.
  • Least-privilege database role in the bundled deployment. docker-compose.yml now runs the gateway as ragmux_app, a plain LOGIN role (no superuser, CREATEDB or CREATEROLE) with CONNECT on the database and CREATE, USAGE on schema public, created on the first start by docker/postgres-init/01-ragmux.sh / 01-ragmux.sql from the new required RAGMUX_DB_PASSWORD. POSTGRES_PASSWORD (the superuser) is only used by that init step, the backup profile and scripts/backup.sh / restore.sh; restore.sh hands the restored tables to the application role afterwards (APP_ROLE). The gateway checks pg_extension before CREATE EXTENSION vector and turns a permission error into the vector extension is missing and the database role may not create it; run "CREATE EXTENSION vector" as a superuser. Deployments upgrading from 0.2.2 keep their superuser DATABASE_URL and keep working (init scripts only run on an empty data volume); switching is optional but recommended: run docker/postgres-init/01-ragmux.sql once by hand, which also grants and hands over the existing tables, then set RAGMUX_DB_PASSWORD (see Database privileges).
  • Per-store upload quotas. RAG stores gain max_documents and max_bytes (migration 0005, 0 = unlimited) and the instance-wide ceilings MAX_DOCUMENTS_PER_STORE / MAX_BYTES_PER_STORE_MB; the effective limit is the smaller non-zero of the two. Uploads that would exceed it are rejected before anything is written with 422 and code: "store_quota" (multi-file uploads check the running total; reprocessing is unaffected). Store responses carry bytes_used; the dashboard form has “Max documents” / “Max size (MB)” fields and the store table shows usage against the quotas. Bounds the storage and embedding cost an editor can cause.

0.2.2 — 2026-09-18

Security

  • SSRF guard for provider URLs. Outbound provider connections are dialled through a resolver that refuses loopback, link-local, multicast and private ranges (DNS rebinding included) unless ALLOW_PRIVATE_UPSTREAMS=true or the host is in PRIVATE_UPSTREAM_ALLOWLIST (e.g. host.docker.internal,ollama); base_url is validated at save time (http/https only, no credentials, query or fragment); redirects are limited to three hops on the same host; HTTP_PROXY is ignored unless private upstreams are allowed.
  • Upstream transport failures are reported as classified messages (upstream unreachable, upstream request timed out, …) instead of raw error text, and relayed provider error bodies are capped at 512 characters. Redaction covers Basic auth, JWTs, Google OAuth, Groq/HF/xAI keys, URL credentials and the connection’s own key literally, including Anthropic and Ollama stream errors.
  • Gemini model_name is URL-escaped as a single path segment and validated ([A-Za-z0-9._:/@-], no ..), closing a path-injection issue.
  • Document parsing is bounded: PDFs stop at 2000 pages and 60 s, DOCX parts over 32 MiB or with a compression ratio above 100 are rejected, extracted text is capped at 20 MiB, documents that split into more than MAX_CHUNKS_PER_DOCUMENT (20000) chunks fail before embedding, ingestion of one document is limited to 15 minutes, chunk_size must be ≥ 200 and a full ingest queue answers 503 instead of silently dropping the document.
  • Streaming provider responses are bounded by STREAM_MAX_DURATION (30m) and STREAM_MAX_BYTES_MB (256).
  • Retrieved passages are labelled as untrusted data in the RAG and rerank prompts and <context> tags inside documents are neutralised, limiting prompt injection from uploads.
  • Behind a proxy the client address is now the last X-Forwarded-For entry (the one the nearest proxy appended) instead of the first; X-Real-IP only counts when X-Forwarded-For is absent. New TRUSTED_PROXY_CIDRS limits proxy headers to connections from those networks and skips them when walking the header.
  • Login: username (≤ 64) and password (≤ 1024) are validated before any database or bcrypt work; the 15-minute lockout is keyed on username and IP so a stranger cannot lock a user out; passwords over bcrypt’s 72-byte limit are refused on create, reset and change instead of being truncated.
  • The login response returns token only when the request sets "bearer": true; the dashboard uses the cookie alone. The cookie is Secure on TLS connections and behind a trusted proxy reporting X-Forwarded-Proto: https, not only with SECURE_COOKIES.
  • Changing your own password revokes every other session of the account.
  • Cookie-authenticated POST/PUT/DELETE requests must be same-origin (Sec-Fetch-Site / Origin vs Host) and JSON bodies must be Content-Type: application/json (403 / 415); bearer requests are unaffected.
  • /admin/api responses are Cache-Control: no-store and carry X-Request-Id; every response carries X-Content-Type-Options, Referrer-Policy and X-Frame-Options; the dashboard is served with a Content-Security-Policy pinning its script by hash.
  • Request bodies get per-route read deadlines (login 10 s, admin JSON 30 s, uploads 5 min, chat 60 s) so idle connections cannot hold handlers open.
  • Unexpected admin errors answer internal error (request id …); the gateway hides provider factory errors behind model connection unavailable.
  • POST /admin/api/models/{id}/test now requires the editor role. Demoting, deactivating or deleting the last active admin is refused atomically, and a project and its members are created in one transaction. A panic inside a provider stream becomes a 502 instead of crashing the process.
  • SECRET_KEY_FILE and DATABASE_URL_FILE read secrets from files; ragmux rotate-key --new <hex> re-encrypts stored provider keys with a new SECRET_KEY.
  • Compose publishes the gateway on 127.0.0.1:8080 and requires POSTGRES_PASSWORD (no more ragmux default); .env.example ships without an admin password. backup.sh runs with umask 077, creates BACKUP_DIR with mode 700 and can put the key file elsewhere (SECRET_KEY_DIR); restore.sh no longer passes credentials on the command line. Dependabot, image provenance/SBOM and a pinned golangci-lint version were added to CI.

0.2.1 — 2026-09-18

Fixed

  • OpenAI-compatible requests carried a stray "Extra": null field, which OpenAI rejects with Unrecognized request argument supplied: Extra. Unknown client fields are still merged into the request; the container field itself is no longer serialised.
  • Connection tests use a 256-token budget so reasoning models (Claude, Gemini, gemma) return a visible reply instead of an empty string.
  • Gemini completion_tokens now includes reasoning (“thoughts”) tokens, so prompt_tokens + completion_tokens == total_tokens as with the other providers.

Verified live against OpenAI (gpt-4o-mini, text-embedding-3-small), Anthropic (claude-opus-5), Gemini (gemini-3.6-flash) and a local Ollama gemma4:12b: JSON and streaming chat, tool calls (OpenAI, Anthropic), hybrid RAG with reranking over MD/DOCX/HTML.

0.2.0 — 2026-09-18

Breaking

  • Storage moved from embedded SQLite/sqlite-vec to PostgreSQL + pgvector. DATABASE_URL is now required and the gateway is deployed together with a Postgres instance (docker-compose.yml bundles pgvector/pgvector:pg17). There is no migration tool for a 0.1 SQLite database.
  • Uploaded documents are stored in the database as bytea; the uploads/ directory is gone.
  • The provider credential encryption key should be supplied via SECRET_KEY (64 hex characters). The on-disk DATA_DIR/secret.key fallback remains for development only.
  • GET /admin/api/system returns database (Postgres, pgvector and migration versions, size), backup and secret_key_source instead of data_dir, db_size_bytes and vector_engine.
  • Proxy headers are no longer trusted by default: the client IP for login limits and the audit log is the TCP peer address unless TRUST_PROXY_HEADERS=true.
  • License changed to AGPL-3.0-or-later.

Added

  • Roles (admin, editor, viewer), a user management API (/admin/api/users…) and a Users dashboard tab; project membership (member_ids, /projects/{id}/members). Non-admins only see their projects and foreign project ids answer 404. Existing accounts become admin on upgrade.
  • Login rate limiting per username and per IP plus a lockout window, stored in Postgres (LOGIN_RATE_LIMIT_PER_MIN, LOGIN_USER_LIMIT_PER_MIN, LOGIN_LOCKOUT_FAILURES, LOGIN_LOCKOUT_MINUTES); 429 with Retry-After.
  • Audit log of logins and every management action, GET /admin/api/audit and an Audit dashboard tab.
  • GET /admin/api/me and the login response include role, is_active and last_login_at.
  • Per-project requests-per-minute / tokens-per-minute limits and daily / monthly token budgets (rate_limit_rpm, rate_limit_tpm, budget_daily_tokens, budget_monthly_tokens; UTC windows shared across replicas). Over the limit the gateway answers 429 with Retry-After, OpenAI-style x-ratelimit-* and x-ragmux-budget-*-remaining headers; GET /admin/api/projects/{id}/usage shows live counters, metrics summaries gain rate_limited, and the dashboard shows limits, usage meters and a 429 tile.
  • Retrieval: hybrid vector + PostgreSQL full-text search fused with reciprocal rank fusion (search_mode, fts_config), section- and page-aware chunks with contextual embeddings (contextual_chunks), optional LLM reranking through the project’s chat model (rerank, rerank_candidates), a cosine distance threshold (max_distance), DOCX and HTML ingestion, the x-ragmux-rag-hits response header, POST /admin/api/rag-stores/{id}/reprocess and per-call search overrides (mode, rerank, max_distance) with rank and score details in the dashboard.
  • Backup and restore: scripts/backup.sh (pg_dump -Fc, verification, rotation, optional SECRET_KEY export), scripts/restore.sh (--yes guard, stops and starts the gateway, waits for /healthz), make backup / make restore, a scheduled backup Compose profile (prodrigestivill/postgres-backup-local), a backup block in GET /admin/api/system and the ragmux -version flag.
  • Retention job (internal/maintenance): request logs older than LOG_RETENTION_DAYS (default 90) and audit entries older than AUDIT_RETENTION_DAYS (default 365) are deleted hourly (0 keeps them forever), together with day-old login attempts, expired sessions and stale usage counters.
  • Native Ollama adapter: the ollama provider type uses /api/chat (NDJSON streaming, tools and tool calls, keep_alive / num_ctx / options passthrough, optional bearer key for proxies) and /api/embed; custom_openai still covers Ollama’s /v1 shim.
  • Request logs record 499 when the client disconnects mid-completion; the upstream call is cancelled in both the JSON and the streaming path.
  • Upstream error messages are scrubbed of API-key-looking strings before they reach logs, the request log or clients.
  • TRUST_PROXY_HEADERS gates the use of X-Forwarded-For / X-Real-IP as the client address.
  • Graceful shutdown: HTTP drains for 15 s, in-flight ingestion jobs get 30 s, then the retention job and the pool stop; interrupted ingestion resumes on the next start.
  • Gateway test suite (auth, validation, error relay, streaming failures, disconnects, degraded RAG, rate limit headers), Ollama adapter and retention tests, golangci-lint v2 in CI, and a release workflow publishing ghcr.io/ragmux/ragmux for linux/amd64 and linux/arm64.
  • Documentation split into docs/ (configuration, API reference, retrieval, users and limits, providers, backup and restore).

Changed

  • docker-compose.dev.yml and make dev-db start a local pgvector Postgres for tests; tests read TEST_DATABASE_URL and use a throwaway schema each.
  • Migrations run at startup under an advisory lock so several replicas can start against the same database.

Known limitations

  • No migration tool from the 0.1 SQLite database to PostgreSQL; set up 0.2 fresh and re-create connections, projects and documents.
  • Gemini connections do not support tool calling (400 when tools is sent).
  • No OCR: scanned PDFs without a text layer are rejected.
  • No Prometheus metrics endpoint; metrics are available through the admin API and dashboard.
  • No SSO / OIDC; authentication is username and password only.

[0.1.0] — 2026-09-18

Initial release: single-container gateway with SQLite + sqlite-vec, OpenAI-compatible proxy for OpenAI, Anthropic, Gemini, DeepSeek, Ollama and custom endpoints, RAG over PDF/TXT/MD, projects with sk-proj- keys, metrics and an embedded dashboard.

No git tag was created for this release, so there is no release page for it. The first tagged release is 0.2.0.

Arrow keys to move, Enter to open.