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_microsof 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,llmrerank on/v1/retrievewas 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 themodel_pricesrow of the connection that ran it and added to the main cost; a spend with no price adds 0 andcost_sourcestays the main call’s. A price row with aper_search_unitrate (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(migration0020, additive) and the price API’sper_search_unitfield: 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
/v1endpoints, andgen_ai.operation.nameof/v1/retrieve.gateway.chat_completionand 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’sgen_ai.operation.nameisretrieval(the convention’s registered value) instead ofretrieve. 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 carriesrerank_skipped: trueandrerank_skipped_reason. Thellmbackend may now keep a chat connection inrerank_connection_id(it used to be cleared); existingllmstores need one set to rerank in the dashboard. A skipped search leavesrerank_fallbackatfalse(a skip is not a failure) and no longer counts as a rerank failure in metrics. /v1accepts the client’sX-Request-Idagain. 0.4.x ignored the header entirely. A/v1request may now send 1 to 128 characters ofA-Z a-z 0-9 . _ : -; Ragmux uses it as the response’sX-Request-Id, stores it inrequest_logs.request_idand logs it asclient_req_id. Anything else is replaced by a generated id, never rejected. The span attributeragmux.request_id, metric labels and thereq_idlog field stay generated, and/admin/apiis unchanged. A value shaped like a credential is accepted, so it can reach your database and logs; see Observability.- A project with routes treats
modelas a selector. In a project that has a chat route, the request’smodelhas to be a route name, the project’s default model name or empty; anything else is404 model_not_foundinstead of being replaced by the connection’s model name and echoed back to the client.GET /v1/modelslists 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}orretrieval {store id}once the request is routed (a request refused earlier keepsgateway.*). Every upstream attempt, retry or fallback, is agateway.attemptchild span carryingragmux.attempt,ragmux.retry,ragmux.fallback, the route and the failure class; provider spans hang beneath it. The request span addsragmux.route,ragmux.attemptsandragmux.fallback.from_connection. See Observability. - Optional OTLP/gRPC trace exporter.
OTEL_EXPORTER_OTLP_PROTOCOL=grpc(orOTEL_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=truemakes 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 scopererankandendpoint = rerankin the request log.modelresolves to akind = rerankroute, else to the RAG store’srerank_connection_id(the implicit route), else404 model_not_found. A rerank API connection (cohere_rerank,voyage_rerank) or a chat connection can serve it. SeePOST /v1/rerank.- Rerank tokens are counted. The tokens of an LLM or API rerank inside a RAG chat or
/v1/retrieverequest are added to that request’s row, and a/v1/rerankcall 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/rerankthrough a chat model scores only what the model ranked. The documents the model ranked get1.0down to1/r; the others follow in request order with a score of0, and an empty answer scores every document0.RERANK_MAX_TOKENSsets the reply cap of the LLM reranker (default200, 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(default2,0disables) sets the retries per target.UPSTREAM_TIMEOUTis now the budget of all attempts together. A request is still onerequest_logsrow, withattemptandfallback_fromfilled in. New metricsragmux_upstream_retries_totalandragmux_upstream_fallbacks_total. See Retry and fallback. - Circuit breaker per model connection. A connection that fails
BREAKER_FAILURE_THRESHOLDtimes in a row (default5,0disables) is skipped by fallback forBREAKER_OPEN_DURATION(default30s), 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 metricragmux_upstream_breaker_state{connection}(0closed,1open,2half-open). See Retry and fallback. request_logsgainsrequest_id,endpoint,requested_model,route,attemptandfallback_from(migrations0016,0017); see the request log.0017builds its indexCONCURRENTLY, outside a transaction.requested_modelis 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 apriority(0-100). A call tries the targets by priority group, in weighted random order inside a group. Managed through/admin/api/projects/{id}/routes(migration0019:project_routes,route_targets); audit eventsroute.create,route.updateandroute.delete. A connection that a route target uses cannot be deleted (409). See Routes and Providers. POST /v1/embeddings. OpenAI-shaped embeddings endpoint (floatandbase64encodings) behind scopeembeddings, withendpoint = embeddingsin the request log.modelresolves to akind = embeddingsroute, else to the RAG store’s embedding connection, else404 model_not_found. SeePOST /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 scoperetrieve. It runs the store’s own search settings. Aviewermember’s key with the scope may call it, because/v1does not read roles. SeePOST /v1/retrieve.ragmux_ingest_embed_tokens_total{source}. Embedding tokens spent by ingestion, split intoreported(the provider’s count) andestimated(characters divided by 4). The “document ingested” log line carriesembed_tokens. Embedding calls of/v1/embeddingsand/v1/retrieveare logged inrequest_logslike any other request.- Azure OpenAI. Provider type
azure_openai(chat, streaming and embeddings), with the resource endpoint inbase_url, the deployment inmodel_nameand an optionalapi-versionquery on thebase_url(default2024-10-21). The key is sent asapi-keyand is covered byrotate-key. See Providers. - Provider presets. Mistral, xAI and Groq are offered as
custom_openaipresets throughGET /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,retrieveandrerankscopes. A key edited in the dashboard keeps the scopes it already had. - Docs drift test.
internal/admin/docs_drift_test.gocompares the JSON field names of the admin API withdocs/api.mdin 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
chatgateway keys gain theembeddings,retrieveandrerankscopes. Migration0018adds the three new scopes to every gateway key that carrieschat, revoked and expired keys included, so those keys can call the new/v1endpoints after the upgrade. It only adds scopes and is safe to run twice; keys with onlymodels, and management keys, are untouched. Edit a key’s scopes afterwards to keep it out of the new endpoints. The project’s defaultsk-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 with400. The signature now travels to the client as an opaquetool_calls[].ragmux_signaturefield (streamed and non-streamed alike) and is written back onto thefunctionCallpart 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_schemais forwarded asgenerationConfig.responseSchemaafter 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-Afteris 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_formatsilently.json_schemaandjson_objectare still ignored, but the response now carriesWarning: 299 ragmux "response_format is not supported for anthropic connections; ignored"(streams included) and the event is logged at info level.Warningis listed inAccess-Control-Expose-Headersso 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_reasonsandgen_ai.usage.cache_read.input_tokens; provider and RAG embedding spans carrygen_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_secondsandragmux_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.yamlships commented exporter examples for Langfuse and Arize Phoenix.
Changed
- Upstream
503and504are relayed as themselves instead of being turned into502, so a client can tell “overloaded or slow, retry later” from “broken”. Every other upstream5xxis still502. Anthropic’s529 overloadedis relayed as503, with the upstream’sRetry-After. - Anthropic
stop_reasonmapping.refusalnow maps tofinish_reason: "content_filter"andmodel_context_window_exceededtolength. Any other unknown value maps tostopand 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
vectortype — 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 byragmux_rag_index_missing.halfvecsupport is planned for v0.6. - A
fts_configother thansimplelogs a warning at startup and on save: the stored full-text column is always built withsimple, 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_PASSWORDstops the start when theuserstable 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.systemspan attribute is superseded bygen_ai.provider.name. Both are emitted in this release so a dashboard can move over;gen_ai.systemis 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_promptkeeps the client’scache_control; when retrieved passages are injected,cache_controlon 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
/healthzexample no longer shows a fixed0.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 wheremax_distanceapplies are stated the same way on every page;0meaning “unlimited” is explained in one place; anddocs/observability.mdno 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 thanREADME.mdbecause 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 withcp .env.example .envanddocker compose up -d, which silently assumed you had already cloned the repository. The first command now pullsragmux/ragmux:latestand needs nothing on disk; Compose follows as the second way in, with thegit clonestep 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/ragmuxkept as a mirror carrying identical tags. The deployment-layout table had been naming the GHCR images while the repository’s own Compose files pullragmux/ragmux. The published-images list also gained the-paradedbvariant 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_KEYcross-reference pointed atscaling.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, migration0010): a key now belongs to a dashboard user and comes in two kinds. A gateway key (sk-user-…) calls/v1on the projects it is granted; a management key (sk-mgmt-…) calls/admin/apiwithin its scopes, replacing the 24-hour session token scripts had to borrow. Both carry an optionalexpires_at, can be revoked, recordlast_used_at, and may set their own rate limits and budgets under the project’s. A key never outranks its owner: on/admin/apiits effective permission is its scopes intersected with the owner’s live role, so demoting a user narrows everysk-mgmt-…key they hold at once. A demotion changes nothing about ansk-user-…key —/v1does not consult roles, andchatandmodelsare covered by every role includingviewer— 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 ownsk-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. NeedsDATABASE_URLand no running gateway, takes the new password from--generateor--stdin(never a flag, which would leak intopsand shell history), and writes an audit row attributed tocli, 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.
toolsandtool_choiceare translated tofunctionDeclarationsandtoolConfig,role:"tool"messages becomefunctionResponseparts (correlated by name, which is all Gemini offers), and function calls are returned as OpenAItool_callsin both the blocking and streaming paths. Tool schemas are rewritten into the OpenAPI subset Gemini accepts: unknown keywords are dropped,const,oneOf/allOfand 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_urlthe 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 byIMAGE_FETCH_MAX_MB,IMAGE_FETCH_TIMEOUTandIMAGE_FETCH_MAX_PER_REQUEST, cached in memory, and switched off entirely withIMAGE_FETCH=false. - Prompt caching passthrough: Anthropic
cache_controlis 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_detailswithcached_tokensandcache_creation_tokens. OpenAI’s caching is automatic and has nothing to send; only its usage is surfaced. - Cost estimation (
model_prices, migration0011): an embedded price table, seeded into the database and editable from the new Prices tab, turns a finished request’s token counts intocost_microson 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_openaiships 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 reportcost_source: "none"until you add a row (Prices tab, orPOST /admin/api/prices). A catch-all at 0 would report a real bill as$0.00withcost_source: "builtin"— a priced zero reads as authoritative where a missing price reads as missing.ollamakeeps its free catch-all: it runs on your own hardware. The shipped table drops the row as ofversion2, so a fresh install never gets it; migration0015deletes it from a database seeded by a pre-release build off thev0.4branch, limited to that pattern and to rows still markedbuiltin, so a row an operator edited isuser, 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 asdocker-compose.paradedb.ymland a:<version>-paradedbimage. PG_SEARCH_TOKENIZER: the analyser for that BM25 index, instance-wide because the index is global.defaultsplits 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>_stemadds 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 thepgvectorfallback at runtime. Pointing the setting somewhere new once the index exists keeps the old analyser — the DDL isCREATE 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, andbm25 index readylogs the analyser queries actually use rather than the configured one.- Cohere and Voyage rerankers:
rerank_backendselects the existing LLM reranker,cohereorvoyage. Their credentials live in amodel_connectionsrow like every other provider secret, so they are encrypted at rest and covered byragmux 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 withFOR UPDATE SKIP LOCKEDand 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.mdanddocker-compose.scale.yml: how to run several replicas, what is already shared, and what must be set first.- A Prometheus
/metricsendpoint and OpenTelemetry traces, both written against the standard library so the dependency list is unchanged./metricsis 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 inboundtraceparentunless 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./healthzstays liveness-only, so container healthchecks are unchanged. The schema comparison is one-directional on purpose: a schema behind the binary answers503(migrating), a schema ahead of it answers200with adegradedentry. Failing readiness on a newer schema would empty the fleet in the middle of amaxSurgerolling 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 a200, not a503— the fallback works, and taking the replica out of rotation would turn a degraded-but-serving instance into an outage.GET /admin/api/search-backendsreports which backends this server can actually run./admin/api/provider-typesnow carries a fullcapabilitiesobject taken from the provider package, so it cannot drift from what the adapters do.IMAGE_FETCH_MAX_CONCURRENT(default16): image fetches the process runs at once, and the image transport’s per-host connection ceiling.IMAGE_FETCH_MAX_PER_REQUESTbounds 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 answers429withRetry-Afterandcode: "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, andRetry-AfternamesIMAGE_FETCH_TIMEOUT, because a slot frees when a download finishes.IMAGE_CACHE_MAX_MB: byte ceiling for the fetched-image cache. Unset, it followsIMAGE_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_KEYis 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-keyre-seals the canary in the same transaction. - Anthropic
prompt_tokensnow 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
503on document upload now means the cluster’s backlog is full, not that this replica’s in-process queue is — genuine cross-replica backpressure, bounded byMAX_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-typesreportssupports_tools: trueforgemini, andsupports_embeddings: falsefordeepseek. The second is not a new restriction — the dashboard has always shown it — butSupportsEmbeddingsdisagreed, 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_urlthat is not a Files API orgs://URI is inlined by the gateway instead of being forwarded asfileData, which Gemini rejected. - Content parts without a
typeare treated as text on every provider, matching OpenAI. Ingester.Resumeis 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’smiddleware.RequestID, which starts from the client’sX-Request-Idheader and only generates an id when it is absent. That id reaches theragmux.request_idspan attribute exported to your collector, theX-Request-Idresponse 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 generatesX-Request-Idand you correlate on it, that correlation stops here: Ragmux now answers with its own id. Configure the proxy to emittraceparentand setTRACING_TRUST_INCOMING=trueto 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 carryreq_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 mintX-Request-Id(nginx$request_id, HAProxyunique-id) do not emittraceparenton their own. - The streaming usage trailer follows
include_usageon every provider. The final usage-only chunk ("choices": []withusage) is now sent only to a client that setstream_options: {"include_usage": true}, the way OpenAI behaves. Previouslyanthropic,geminiandollamaalways sent it, and the OpenAI-compatible path relayed the one it requests upstream, so clients that indexchunk.choices[0]on every chunk could break. If you read token counts off a stream, setinclude_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 thegemini,anthropicandollamaconnections. 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 own400naming what is wrong; these used to travel to the provider and come back as an upstream400that 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.
geminiaccepts png, jpeg, webp, heic and heif;anthropicandollamaaccept png, jpeg, gif and webp. - A redirect that drops from
httpstohttpis refused, on the same host as well as across hosts: same host is not the same connection, and the retry would put theAuthorizationheader on the wire in the clear. - An inline
data:URL is recognised whatever the case of its scheme.DATA:image/pngused to be forwarded as a remote URL.
Fixed
- Ollama tool calls spread over several streaming lines were all given index
0and merged by clients into one corrupt call; they are now numbered across the whole stream. - Browser clients could not read
x-ratelimit-*,x-ragmux-*orRetry-After: the responses never carriedAccess-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.documentspan.
Security
- A management key is accepted only from the
Authorizationheader. 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 anAuthorizationheader 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
401with acodeexplaining 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/setupstops 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, whereSECRET_KEYcame from and — since the wizard landed —has_connectionsandhas_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 inchunksbefore it answers. The build takes aSHARElock, so ingest writes tochunksblock until it finishes, and it is notCONCURRENTLY: 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 nostemmerfor the default analyser — whichever matchesPG_SEARCH_TOKENIZER.CREATE INDEX CONCURRENTLYcannot run inside a transaction block, so send it on its own — not throughpsql -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.tokenizeragainstPG_SEARCH_TOKENIZERand warns when they differ. Nothing compareskey_fieldornumeric_fields, so an index built here withoutnumeric_fieldsis accepted in silence, answers queries, and keepsrag_store_idoff the fast-field path for good —CREATE INDEX IF NOT EXISTSwill 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_bm25first is not enough; see Backup and restore.
0.3.1 — 2026-09-18
Added
- All-in-one container image (
Dockerfile.aio, published asghcr.io/ragmux/ragmux:<version>and:latest): PostgreSQL 17 with pgvector and the gateway in one container, supervised bydocker/aio/entrypoint.sh. One volume at/dataholds the database (/data/pg) and thesecret.keyfallback (/data/ragmux); Postgres listens on a unix socket only (no TCP, trust authentication inside the container) and the gateway connects as the least-privilege roleragmux_app.SIGTERMstops the gateway first, then Postgres (pg_ctl stop -m fast); if either process dies the container exits non-zero.DATABASE_URL(orEMBEDDED_POSTGRES=false) skips the embedded server;PG_SHARED_BUFFERStunes it;ragmux-aio postgres-onlyruns Postgres alone for maintenance. Documented in Deployment layouts. Superseded by 0.4.1: Docker Hub (ragmux/ragmux) is the primary registry; theghcr.ionames above remain as a mirror with identical tags. scripts/backup.shandscripts/restore.shdetect the Compose layout (LAYOUT=auto|split|aio): in the all-in-one layout they runpg_dump/pg_restoreinside theragmuxcontainer aspostgresover the socket, and the restore goes through a one-offpostgres-onlycontainer while the service is stopped.- The
backupCompose profile works with the all-in-one file over a shared socket volume. make docker-build-appbuilds the gateway-only image;make docker-buildnow builds the all-in-one image.
Changed
- Breaking for Compose users:
docker-compose.ymlis now the single-container layout (ragmuxservice built fromDockerfile.aio, volumeragmux-data, nothing required in.env;SECRET_KEYrecommended). The previous two-service file (gateway image + separatepgvector/pgvector:pg17, requiringSECRET_KEY,POSTGRES_PASSWORDandRAGMUX_DB_PASSWORD) is unchanged in behaviour but renamed todocker-compose.split.yml(its local image tag becomesragmux/ragmux:latest-app, matching the published-apptag): existing deployments add-f docker-compose.split.yml(orCOMPOSE_FILEin.env) to keep theirpgdatavolume, 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:latestnow point at the all-in-one image; the gateway-only distroless image is published as:<version>-app,:<major>.<minor>-appand:latest-app. Both are built forlinux/amd64andlinux/arm64and signed with cosign by digest; CI builds both Dockerfiles. docker/postgres-init/01-ragmux.sqlis shared by both layouts: theragmux_apppassword is only set when thepwpsql variable is passed (the split init script still passes it)..env.examplelists only optional variables for the default file; the split-layout passwords sit in a separate block at the end.- Default port is now
8765(was8080): the binary, the Docker image, the Compose file, the health check and the documentation all use it. SetPORT=8080to 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 withcosign verifyas 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 gainscontents: write,packages: writeandid-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/Extendsor xref/Prevcycle, 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/Kidsand/Parentcycles end at once andpage_countreports 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-levelragmuxobject on non-streaming responses; the field is stripped before the upstream call and streaming responses only get the header. rag_hitsin request logs (migration 0007): the number of passages injected per request, next torag_used, in/admin/api/metrics/requestsand the summary’srecent.- Metrics summary options.
GET /admin/api/metrics/summary(and/projects/{id}/metrics) acceptcompare=1for apreviousblock covering the window before the current one,days=N(1–90) to size thedailyseries andby_project=1for a per-projectprojectsbreakdown under the caller’s membership scope. - CSV export.
GET /admin/api/metrics/requests.csv?window=&project_id=andGET /admin/api/projects/{id}/metrics.csv?window=download up to 50 000 request logs of the window astext/csvwith 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}/usagegainsforecast.daily_exhausted_at/monthly_exhausted_at: a linear projection from the tokens consumed in the last 60 minutes,nullwithout a budget, without recent usage, or when the budget outlasts its window. POST /admin/api/models/testtests a connection before it is saved, with the same validation, private-address check and credential redaction as create;connection_idwith an emptyapi_keyreuses the stored key of an existing connection.- Connection test outcomes are recorded:
POST /models/{id}/testwriteslast_test_at,last_test_ok,last_test_latency_msandlast_test_error(redacted, at most 512 characters), returned by the list and get endpoints (migration0009). - Connections report
private_upstream: whetherbase_urlpoints atlocalhost, a loopback, private or link-local address (hostnames are classified when the connection is saved and cached inupstream_private; the provider default URL counts as public). GET /admin/api/provider-typesreportssupports_tools(false forgemini) andsupports_streaming.- Documents carry
page_count(PDF pages,nullfor other formats) andprogress_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}/searchreturnsretrieval_latency_msandrerank_latency_ms(nullwhen reranking did not run) next tolatency_ms.GET /admin/api/audittakessinceanduntil(RFC 3339) next toaction,actor_user_id,beforeandlimit, and answers{"entries": […], "total": N, "has_more": bool}wheretotalcounts 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 asaudit.exportedwith the filters indetails.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) carryattempts_remaining(per-user budget minus recorded failures, identical for unknown usernames and wrong passwords);429answers gain"locked": true|falsenext toRetry-After. GET /admin/api/mereportssession_expires_atandsession_bearer.GET /admin/api/usersentries carryproject_countandactive_sessions;POST /admin/api/usersacceptsproject_idsand writes the memberships in the same transaction (422for an unknown project).GET /admin/api/setupreportsmigrations_version,secret_key_sourceanddatabase_roleso 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 underweb/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 answers409; the stored casing is preserved. Migration0008adds a unique index onlower(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/systemreturnsdatabase.postgres_versionanddatabase.pgvector_versionto admins only; editors and viewers receive thedatabaseobject 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
adminaccount 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 byGET/POST /admin/api/setup, which only work while theuserstable is empty and refuse with409afterwards; 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 assetup.complete.ADMIN_USER/ADMIN_PASSWORDstill pre-create the account for unattended installs; withoutADMIN_PASSWORDthe log saysno 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_encis 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 sameSECRET_KEY;ragmux rotate-keyalso writes the bound form. - Least-privilege database role in the bundled deployment.
docker-compose.ymlnow runs the gateway asragmux_app, a plainLOGINrole (no superuser,CREATEDBorCREATEROLE) withCONNECTon the database andCREATE, USAGEon schemapublic, created on the first start bydocker/postgres-init/01-ragmux.sh/01-ragmux.sqlfrom the new requiredRAGMUX_DB_PASSWORD.POSTGRES_PASSWORD(the superuser) is only used by that init step, thebackupprofile andscripts/backup.sh/restore.sh;restore.shhands the restored tables to the application role afterwards (APP_ROLE). The gateway checkspg_extensionbeforeCREATE EXTENSION vectorand turns a permission error intothe 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 superuserDATABASE_URLand keep working (init scripts only run on an empty data volume); switching is optional but recommended: rundocker/postgres-init/01-ragmux.sqlonce by hand, which also grants and hands over the existing tables, then setRAGMUX_DB_PASSWORD(see Database privileges). - Per-store upload quotas. RAG stores gain
max_documentsandmax_bytes(migration0005,0= unlimited) and the instance-wide ceilingsMAX_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 with422andcode: "store_quota"(multi-file uploads check the running total; reprocessing is unaffected). Store responses carrybytes_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=trueor the host is inPRIVATE_UPSTREAM_ALLOWLIST(e.g.host.docker.internal,ollama);base_urlis validated at save time (http/https only, no credentials, query or fragment); redirects are limited to three hops on the same host;HTTP_PROXYis 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_nameis 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_sizemust be ≥ 200 and a full ingest queue answers503instead of silently dropping the document. - Streaming provider responses are bounded by
STREAM_MAX_DURATION(30m) andSTREAM_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-Forentry (the one the nearest proxy appended) instead of the first;X-Real-IPonly counts whenX-Forwarded-Foris absent. NewTRUSTED_PROXY_CIDRSlimits 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
tokenonly when the request sets"bearer": true; the dashboard uses the cookie alone. The cookie isSecureon TLS connections and behind a trusted proxy reportingX-Forwarded-Proto: https, not only withSECURE_COOKIES. - Changing your own password revokes every other session of the account.
- Cookie-authenticated
POST/PUT/DELETErequests must be same-origin (Sec-Fetch-Site/OriginvsHost) and JSON bodies must beContent-Type: application/json(403/415); bearer requests are unaffected. /admin/apiresponses areCache-Control: no-storeand carryX-Request-Id; every response carriesX-Content-Type-Options,Referrer-PolicyandX-Frame-Options; the dashboard is served with aContent-Security-Policypinning 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 behindmodel connection unavailable. POST /admin/api/models/{id}/testnow 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 a502instead of crashing the process.SECRET_KEY_FILEandDATABASE_URL_FILEread secrets from files;ragmux rotate-key --new <hex>re-encrypts stored provider keys with a newSECRET_KEY.- Compose publishes the gateway on
127.0.0.1:8080and requiresPOSTGRES_PASSWORD(no moreragmuxdefault);.env.exampleships without an admin password.backup.shruns withumask 077, createsBACKUP_DIRwith mode700and can put the key file elsewhere (SECRET_KEY_DIR);restore.shno 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": nullfield, which OpenAI rejects withUnrecognized 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_tokensnow includes reasoning (“thoughts”) tokens, soprompt_tokens + completion_tokens == total_tokensas 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_URLis now required and the gateway is deployed together with a Postgres instance (docker-compose.ymlbundlespgvector/pgvector:pg17). There is no migration tool for a 0.1 SQLite database. - Uploaded documents are stored in the database as
bytea; theuploads/directory is gone. - The provider credential encryption key should be supplied via
SECRET_KEY(64 hex characters). The on-diskDATA_DIR/secret.keyfallback remains for development only. GET /admin/api/systemreturnsdatabase(Postgres, pgvector and migration versions, size),backupandsecret_key_sourceinstead ofdata_dir,db_size_bytesandvector_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 answer404. Existing accounts becomeadminon 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);429withRetry-After. - Audit log of logins and every management action,
GET /admin/api/auditand an Audit dashboard tab. GET /admin/api/meand the login response includerole,is_activeandlast_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 answers429withRetry-After, OpenAI-stylex-ratelimit-*andx-ragmux-budget-*-remainingheaders;GET /admin/api/projects/{id}/usageshows live counters, metrics summaries gainrate_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, thex-ragmux-rag-hitsresponse header,POST /admin/api/rag-stores/{id}/reprocessand 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, optionalSECRET_KEYexport),scripts/restore.sh(--yesguard, stops and starts the gateway, waits for/healthz),make backup/make restore, a scheduledbackupCompose profile (prodrigestivill/postgres-backup-local), abackupblock inGET /admin/api/systemand theragmux -versionflag. - Retention job (
internal/maintenance): request logs older thanLOG_RETENTION_DAYS(default 90) and audit entries older thanAUDIT_RETENTION_DAYS(default 365) are deleted hourly (0keeps them forever), together with day-old login attempts, expired sessions and stale usage counters. - Native Ollama adapter: the
ollamaprovider type uses/api/chat(NDJSON streaming, tools and tool calls,keep_alive/num_ctx/optionspassthrough, optional bearer key for proxies) and/api/embed;custom_openaistill covers Ollama’s/v1shim. - Request logs record
499when 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_HEADERSgates the use ofX-Forwarded-For/X-Real-IPas 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/ragmuxforlinux/amd64andlinux/arm64. - Documentation split into
docs/(configuration, API reference, retrieval, users and limits, providers, backup and restore).
Changed
docker-compose.dev.ymlandmake dev-dbstart a local pgvector Postgres for tests; tests readTEST_DATABASE_URLand 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 (
400whentoolsis 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.