# Changelog > All notable changes to Ragmux are documented here. The format follows Keep a Changelog and the project uses Semantic Versioning. Source: https://ragmux.com/docs/changelog/ Updated: 2026-10-11 Project: https://github.com/ragmux/ragmux (AGPL-3.0-or-later) --- All notable changes to Ragmux are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). ## [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](/docs/observability/#cardinality-checklist). - **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](/docs/api/#routes) and [Providers](/docs/providers/#routing). ### 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](/docs/observability/#the-spans). - **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](/docs/observability/#exporting-over-grpc). - **`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`](/docs/api/#post-v1rerank). - **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](/docs/providers/#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](/docs/providers/#retry-and-fallback). - `request_logs` gains `request_id`, `endpoint`, `requested_model`, `route`, `attempt` and `fallback_from` (migrations `0016`, `0017`); see [the request log](/docs/observability/#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](/docs/api/#routes) and [Providers](/docs/providers/#routing). - **`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`](/docs/api/#post-v1embeddings). - **`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`](/docs/api/#post-v1retrieve). - **`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](/docs/providers/#azure-openai). - **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](/docs/providers/#presets). - **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](/docs/users-and-limits/#api-keys). ## [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](/docs/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](/docs/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](/docs/configuration/) and [Backup and restore](/docs/backup-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](/docs/users-and-limits/#api-keys). - **`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 `**: 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 `$ref`s 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](/docs/providers/#gemini). - **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 `:-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. `_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](/docs/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](/docs/observability/#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: ```sql 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: ```sql 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](/docs/backup-restore/#restoring-a-paradedb-dump-onto-a-plain-pgvector-server). ## [0.3.1] — 2026-09-18 ### Added - **All-in-one container image** (`Dockerfile.aio`, published as `ghcr.io/ragmux/ragmux:` 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](/docs/configuration/#deployment-layouts). *Superseded by [0.4.1](#041--2026-09-19): 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](/docs/backup-restore/#moving-between-layouts)). No automatic migration of the data volume. - **Image tags.** `ghcr.io/ragmux/ragmux:`, `:.` and `:latest` now point at the all-in-one image; the gateway-only distroless image is published as `:-app`, `:.-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](/docs/security/#verifying-the-container-image); 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](/docs/users-and-limits/#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](/docs/configuration/#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:1 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 `` 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 ` 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](https://github.com/ragmux/ragmux/releases/tag/v0.2.0). [Unreleased]: https://github.com/ragmux/ragmux/compare/v0.5.0...HEAD [0.5.0]: https://github.com/ragmux/ragmux/releases/tag/v0.5.0 [0.4.2]: https://github.com/ragmux/ragmux/releases/tag/v0.4.2 [0.4.1]: https://github.com/ragmux/ragmux/releases/tag/v0.4.1 [0.4.0]: https://github.com/ragmux/ragmux/releases/tag/v0.4.0 [0.3.1]: https://github.com/ragmux/ragmux/releases/tag/v0.3.1 [0.3.0]: https://github.com/ragmux/ragmux/releases/tag/v0.3.0 [0.2.3]: https://github.com/ragmux/ragmux/releases/tag/v0.2.3 [0.2.2]: https://github.com/ragmux/ragmux/releases/tag/v0.2.2 [0.2.1]: https://github.com/ragmux/ragmux/releases/tag/v0.2.1 [0.2.0]: https://github.com/ragmux/ragmux/releases/tag/v0.2.0