Guides
Providers
A model connection is one provider type plus a base URL, an API key and a model name. Projects route chat requests through one connection; RAG stores use one for embeddings.
Updated:
A model connection is one provider type plus a base URL, an API key and a model
name. Projects route chat requests through one connection; RAG stores use one for
embeddings. Credentials are AES-256-GCM encrypted with SECRET_KEY and never leave the
gateway. Connections are managed with POST /admin/api/models (see the
API reference) or in the dashboard’s Models tab;
GET /admin/api/provider-types lists the types with their defaults.
Provider types
provider_type |
Default base_url |
Chat endpoint used | Embeddings | Credentials |
|---|---|---|---|---|
openai |
https://api.openai.com/v1 |
POST {base}/chat/completions |
yes — POST {base}/embeddings |
Authorization: Bearer <api_key> |
anthropic |
https://api.anthropic.com |
POST {base}/v1/messages |
no | x-api-key: <api_key>, anthropic-version: 2023-06-01 |
gemini |
https://generativelanguage.googleapis.com/v1beta |
POST {base}/models/{model}:generateContent (:streamGenerateContent?alt=sse when streaming) |
yes — POST {base}/models/{model}:batchEmbedContents |
x-goog-api-key: <api_key> |
deepseek |
https://api.deepseek.com/v1 |
POST {base}/chat/completions |
reported as unsupported (supports_embeddings: false) |
Authorization: Bearer <api_key> |
ollama |
http://localhost:11434 |
POST {base}/api/chat (native API) |
yes — POST {base}/api/embed |
none; an api_key is sent as Authorization: Bearer for a proxy in front of Ollama |
custom_openai |
(required; dashboard suggests http://localhost:8000/v1) |
POST {base}/chat/completions |
yes — POST {base}/embeddings |
Authorization: Bearer <api_key> (optional) |
azure_openai |
(required; dashboard suggests https://your-resource.openai.azure.com) |
POST {endpoint}/openai/deployments/{deployment}/chat/completions?api-version=… |
yes — POST {endpoint}/openai/deployments/{deployment}/embeddings?api-version=… |
api-key: <api_key> (no Authorization header) |
cohere_rerank |
https://api.cohere.com |
none — reranking only | no | Authorization: Bearer <api_key> |
voyage_rerank |
https://api.voyageai.com |
none — reranking only | no | Authorization: Bearer <api_key> |
Base URL handling: a trailing / is trimmed. For openai and deepseek a /v1 suffix
is appended when the URL does not already end in one. For custom_openai the URL is used
exactly as given (so include /v1 yourself, e.g. http://vllm:8000/v1). For ollama a
trailing /v1 is removed because the native endpoints live at the root. When the gateway
runs in Docker, local servers on the host are reachable as http://host.docker.internal:<port>.
A base_url must be a plain http:// or https:// URL with a host and without
credentials, query string or fragment (azure_openai alone may carry an api-version
query parameter, see Azure OpenAI). Hosts on private or local networks (the Docker
host, a Compose service, localhost) are refused unless they are listed in
PRIVATE_UPSTREAM_ALLOWLIST or ALLOW_PRIVATE_UPSTREAMS=true is set — see
Private upstreams. model_name may contain letters,
digits and . _ : / @ - (up to 128 characters, no leading /, no ..); for gemini it
is sent as a single escaped path segment.
POST /admin/api/models/{id}/test with {"mode":"chat"} or {"mode":"embedding"}
sends a ping through the connection and reports the reply, or the dimensions for
embeddings, together with latency; the outcome is kept on the connection
(last_test_at, last_test_ok, last_test_latency_ms, last_test_error). The same
test runs on a connection that is not saved yet through POST /admin/api/models/test,
which takes the create body plus mode and, with an empty api_key, a connection_id
whose stored key is reused. Each connection also reports private_upstream: whether its
base_url pointed at a loopback, private or link-local address when it was saved. See
REST API reference.
Presets
Hosted providers that speak the OpenAI Chat Completions format need no adapter of their
own: they are custom_openai connections with a known base URL. GET /admin/api/provider-presets lists the ready-made ones, each with the provider_type,
base_url and model names to use, so a client can offer them as a one-step choice:
Preset id |
Provider | provider_type |
base_url |
Suggested model_name |
|---|---|---|---|---|
mistral |
Mistral AI | custom_openai |
https://api.mistral.ai/v1 |
mistral-large-latest, mistral-medium-latest, mistral-small-latest, codestral-latest; embeddings: mistral-embed |
xai |
xAI | custom_openai |
https://api.x.ai/v1 |
grok-4, grok-3, grok-3-mini |
groq |
Groq | custom_openai |
https://api.groq.com/openai/v1 |
llama-3.3-70b-versatile, llama-3.1-8b-instant, openai/gpt-oss-120b, openai/gpt-oss-20b |
Every preset needs an API key (requires_api_key: true), sent as Authorization: Bearer.
A preset is a convenience, not a stored type: send its provider_type and base_url,
and a model_name of your choice, to POST /admin/api/models. The result is an ordinary
custom_openai connection that is validated and tested like one typed by hand. The model names are suggestions; any name the
provider serves works, and a provider that retires one does not break existing
connections.
In the dashboard the same list is the Start from a preset select on the new-connection form: picking one fills in the provider type, base URL and a model name, and nothing is saved until you press Save connection. The preset itself is never sent.
Presets carry no price. custom_openai ships no built-in price rows, because the type
names a URL rather than a vendor and a row keyed on it would price every self-hosted
server like the hosted service. Add the models you use to the
price table (provider type custom_openai) to get cost figures;
until then usage is counted and a request is logged with cost_source: "none" and a cost
of zero.
Capabilities
What each adapter can do with a provider type. These describe the adapter, not the
upstream model: a model that cannot see images still shows vision for its provider
type, because the translation exists — the model’s own refusal is relayed as it comes.
openai |
anthropic |
gemini |
deepseek |
ollama |
custom_openai |
azure_openai |
|
|---|---|---|---|---|---|---|---|
chat |
yes | yes | yes | yes | yes | yes | yes |
streaming |
yes | yes | yes | yes | yes | yes | yes |
embeddings |
yes | no | yes | no | yes | yes | yes |
tools |
yes | yes | yes | yes | yes | yes | yes |
tool_streaming |
yes | yes | no ¹ | yes | no ¹ | yes | yes |
vision |
yes | yes | yes | no | yes | yes | yes |
remote_images |
yes | yes | no ² | no | no ² | yes | yes |
prompt_caching |
no ³ | yes | no | no | no | no | no ³ |
cached_token_usage |
yes | yes | yes | yes | no | no | yes |
rerank |
no | no | no | no | no | no | no |
cohere_rerank and voyage_rerank sit outside that table: rerank is the only
capability they have, and chat and embeddings are both no. chat is what the
dashboard’s model picker filters on: a connection whose type has it can back a project
or a chat route target.
¹ Tool calls still work in streams; they arrive as one complete delta per call instead of argument fragments, because that is how the upstream sends them — see Ollama and Gemini. ² The upstream cannot fetch an image URL, so the gateway downloads it and sends the bytes; see Images. ³ OpenAI and Azure OpenAI cache prompts automatically, so there is no marker to send.
The table gates nothing: an adapter answers for itself with a typed error when a request asks for something it cannot do, and a second copy of those rules in front of it would only drift. It is a description, for the dashboard and for this page.
GET /admin/api/provider-types reports it per type: the flat supports_embeddings,
requires_api_key, supports_streaming and supports_tools fields, plus a
capabilities object with the full table above.
Rerank providers (cohere_rerank, voyage_rerank)
These two types are connections that can do exactly one thing: reorder retrieved
passages by relevance to a query. They cannot back a project and they cannot back a
RAG store’s embeddings. provider.New and NewEmbedder reject them, the dashboard’s
model and embedding pickers filter them out, and saving a project or a store that names
one is refused with 400 (provider "cohere_rerank" cannot be used for chat,
... cannot be used for embeddings). The only place a connection of these types belongs
is a RAG store’s rerank_connection_id or a kind = rerank route target (served by
POST /v1/rerank) — see Reranking.
cohere_rerank |
voyage_rerank |
|
|---|---|---|
| Endpoint | POST {base}/v2/rerank |
POST {base}/v1/rerank |
| Request | {"model","query","documents","top_n"} |
{"model","query","documents","top_k","truncation":true} |
| Response | {"results":[{"index","relevance_score"}]} |
{"data":[{"index","relevance_score"}]} |
model_name |
e.g. rerank-v3.5 |
e.g. rerank-2.5 |
They are ordinary model connections in every other respect: the API key is AES-256-GCM
encrypted at rest with the connection id as additional authenticated data, it is covered
by ragmux rotate-key, it is masked in the API, the base_url goes through the same
SSRF guard when it is saved, and the call itself travels the gateway’s one hardened
outbound path — the netguard dialer, the redirect cap, the fixed transport-error
messages, redaction and the response body cap. POST /admin/api/models/{id}/test does
not apply to them: they answer neither a chat nor an embedding ping. Test them from the
RAG store’s search panel instead, which shows whether reranking ran or was skipped.
Each passage is cut to 4000 characters before it is sent. Indexes the API answers with
are validated against the list that was sent: anything out of range or repeated is
dropped, and a reply with nothing usable left is a 502. Every failure — a timeout, a
429, a malformed body — leaves the fused retrieval order in place; the request is
answered without reranking and the reason is logged.
OpenAI-compatible (openai, deepseek, custom_openai)
The client request is forwarded as-is apart from model (replaced by the connection’s
model name), stream (set by the gateway) and stream_options (set to
{"include_usage": true} on streams so token counts are recorded; removed on
non-streaming calls).
Top-level request fields travel as sent (see
Request field passthrough), but inside messages only
role, content, name, tool_calls and tool_call_id are kept; the response is
rewritten as well. Replies and SSE chunks are
decoded into the gateway’s own OpenAI-shaped structures and written back out, so only the
fields it knows come through:
- top level:
id,object,created,model,choicesandusage(withprompt_tokens_detailsandcompletion_tokens_details); - per choice:
index,finish_reasonand amessage(adeltaon streams) holdingrole,contentandtool_calls.
Anything else the upstream adds is not forwarded: reasoning_content, refusal,
annotations, logprobs and system_fingerprint are all dropped, even when the request
asked for them (logprobs: true reaches the upstream, as the passthrough below says, and
its answer does not come back). A client that needs one of them cannot get it through the
gateway in this release.
What the gateway asks the upstream for is not what it passes on: the usage-only trailer
chunk reaches the client only when the client itself sent
stream_options: {"include_usage": true}. The same rule covers anthropic, gemini
and ollama, whose adapters all emit that trailer so token counts are recorded — see
the streaming contract.
custom_openai covers vLLM, LM Studio, LiteLLM, text-generation-inference’s OpenAI
route, Ollama’s /v1 shim and any other server speaking the Chat Completions format.
Prompt caching on these providers is automatic and server-side: nothing is sent for it,
and prompt_tokens_details.cached_tokens (DeepSeek’s prompt_cache_hit_tokens) is
surfaced back to the client. See Prompt caching.
Request field passthrough (Extra)
/v1/chat/completions parses the known fields (model, messages, stream,
temperature, top_p, max_tokens, max_completion_tokens, stop, n, tools,
tool_choice, response_format, stream_options, user) and keeps every other
top-level field verbatim. For OpenAI-compatible providers those extra fields are sent to
the upstream unchanged, so provider-specific options (seed, a request-side logprobs,
reasoning_effort, vLLM’s guided_json, …) work without gateway support. For ollama a
defined subset is
mapped (below); anthropic and gemini ignore unknown fields.
Azure OpenAI
azure_openai talks to an Azure OpenAI resource. It uses the Chat Completions body and
response, so streaming, tool calls, images and embeddings behave as they do for openai;
what differs is how the request is addressed and authenticated:
POST {endpoint}/openai/deployments/{deployment}/chat/completions?api-version={version}
POST {endpoint}/openai/deployments/{deployment}/embeddings?api-version={version}
api-key: <api_key>
The key travels in the api-key header; no Authorization: Bearer header is sent. A
model connection has no column for the version, so the four settings map onto the
existing fields:
| Setting | Field | Example |
|---|---|---|
| Endpoint | base_url |
https://my-resource.openai.azure.com |
| Deployment | model_name |
my-gpt4o (the deployment name you chose in Azure, not the model family) |
| API version | api-version query parameter of base_url, optional |
https://my-resource.openai.azure.com?api-version=2025-04-01-preview |
| Key | api_key |
one of the resource’s two keys |
$AUTH is the bearer header from the login example in the API reference.
curl -X POST http://localhost:8765/admin/api/models \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"azure-gpt4o","provider_type":"azure_openai",
"base_url":"https://my-resource.openai.azure.com",
"model_name":"my-gpt4o","api_key":"<azure key>"}'
Without an api-version the gateway uses 2024-10-21. Pass another one when a feature
you need (a newer model, a preview capability) requires it. The same connection can serve
a RAG store’s embeddings when its deployment is an embedding model; chat and embedding
deployments are separate connections.
The dashboard’s connection form shows these settings under their Azure names (endpoint,
Deployment name, API version) and joins them into base_url and model_name on
save. Leave the API version blank to use the default.
Validation on save: base_url is required and takes no credentials or fragment, and its
only allowed query parameter is a single api-version (letters, digits, ., _, -);
a pasted trailing / or /openai is accepted and removed. model_name is the deployment
name: letters, digits, ., _ and -, starting with a letter or digit, up to 64
characters. The endpoint goes through the same private-address check as every other
base_url; use PRIVATE_UPSTREAM_ALLOWLIST for a private endpoint such as an Azure
Private Link address.
The key sits in the same encrypted api_key field as every other provider key: AES-256-GCM
at rest, masked in the API, and re-encrypted by ragmux rotate-key along with the rest.
Upstream errors are relayed and redacted like any other provider’s, so the key never
appears in one.
Azure prices are not built in; add them to the price table with
provider type azure_openai.
Anthropic
Requests are translated to the Messages API and responses back to the OpenAI schema:
systemanddevelopermessages are removed from the message list, joined with blank lines and sent as the top-levelsystemfield (this is where the project system prompt and RAG context end up). With acache_controlon one of them the field becomes an array of blocks instead — see Prompt caching.max_tokensis required by Anthropic; when the client sends neithermax_tokensnormax_completion_tokensthe gateway uses 4096.temperature,top_pandstopare mapped;n,response_format,userand unknown fields are dropped.response_formatof typejson_schemaorjson_objectis not an error: the request is still sent, without it, so the model is not held to the schema. The response carriesWarning: 299 ragmux "response_format is not supported for anthropic connections; ignored"(set before the first byte, so streams have it too) and the gateway logs the event at info level with the format type only. Ask for JSON in the prompt instead.- Consecutive messages with the same role are merged, as the Messages API requires.
- Tools: OpenAI
tools[].functiondefinitions become Anthropictools(input_schemafromparameters,{"type":"object","properties":{}}when absent);tool_choice"auto"→{"type":"auto"},"required"→{"type":"any"}, a named function →{"type":"tool","name":…},"none"removes the tools. Assistanttool_callsbecometool_useblocks,toolmessages becometool_resultblocks, andtool_usein the reply becomes OpenAItool_calls(also in streams, viainput_json_delta). - Image parts:
image_urlwith adata:URL is sent as a base64 image block, any other URL as a URL image block — Anthropic fetches it itself, which is cheaper than relaying the bytes through the gateway. A Bedrock-style gateway in front of Anthropic that has nosource.type: urlneeds the images inlined instead; that is the one constantanthropicInlineImagesininternal/provider/images.go. - Finish reasons:
end_turn/stop_sequence→stop,max_tokens→length,tool_use→tool_calls,refusal→content_filter,model_context_window_exceeded→length; any otherstop_reason(pause_turn, a value Anthropic adds later) →stop, logged at warn level with the value. Usage is mapped toprompt_tokens/completion_tokens, including on streams; see Prompt caching for how the cache counters are folded in.
Anthropic has no embeddings API; choose another connection for RAG stores.
Gemini
Requests are translated to generateContent / streamGenerateContent:
system/developermessages becomesystemInstruction;assistantturns are sent with rolemodel.temperature,top_p,max_tokens/max_completion_tokensandstopmap togenerationConfig(topP,maxOutputTokens,stopSequences); aresponse_formatwhosetypestarts withjsonsetsresponseMimeType: application/json.- Image parts: a
data:URL becomesinlineData.fileData.fileUricarries only what Gemini can resolve itself — a Files API object (https://generativelanguage.googleapis.com/v1beta/files/…) or ags://path. Every other image URL is downloaded by the gateway and sent asinlineData; withIMAGE_FETCH=falsesuch a request is refused with400. See Images. - Finish reasons:
STOP→stop,MAX_TOKENS→length, safety-related reasons (SAFETY,RECITATION,BLOCKLIST,PROHIBITED_CONTENT,SPII) →content_filter. A turn that called tools reportstool_callswhatever Gemini said, because Gemini has no distinct reason for it.usageMetadatais mapped to OpenAI usage, includingcachedContentTokenCount— see Prompt caching. Explicit caching (cachedContents) is not supported. - Embeddings use
batchEmbedContents; the model name gets amodels/prefix if missing.
Tool calling
Gemini has function calling but neither call ids nor JSON Schema, so the translation does more work than elsewhere:
-
tools[].functiondefinitions become onefunctionDeclarationslist;tool_choicemaps totoolConfig.functionCallingConfig:"auto"→AUTO,"required"→ANY,"none"→NONE, a named function →ANYwithallowedFunctionNames. Unlike the Anthropic path,"none"keeps the declarations — Gemini has an exact equivalent, so the model still knows the tools exist. -
Assistant
tool_callsbecomefunctionCallparts; arguments that are not a JSON object are sent as{}. -
Results are correlated by function name, not by id. A
toolmessage’stool_call_idis looked up in the assistanttool_callsthat preceded it and the recovered name is sent as afunctionResponsewith roleuser(Content.roleonly takesuserormodel; consecutive ones merge, which is what parallel results need). A result whose call is not in the message history is dropped, with a debug log line, rather than turned into an upstream400.responsemust be a JSON object: the content is used as-is when it parses as one, otherwise wrapped as{"result": "<text>"}. -
In streams a
functionCallalways arrives complete inside one chunk, so each call is emitted as onetool_callsdelta carrying the index, id, name and arguments together. No argument fragments are invented. -
Thought signatures round-trip through the client. A thinking model attaches an opaque
thoughtSignatureto the function call it makes, and expects it back when the tool result is sent. Ragmux relays it on each tool call as an extra field,ragmux_signature, in the response (the JSON reply and the streamed delta alike), and puts it back on thefunctionCallpart when an assistant message carrying it comes in on the next turn. A client that appends the assistant message to the history unchanged — as the OpenAI SDKs do with the message object they received — sends it back without doing anything. Under parallel calling only the first call carries a signature. The field means nothing outside the connection that issued it: OpenAI-compatible connections have it removed from the request before it is sent.Known limit. A client that rebuilds the tool calls by hand — in a stream, collecting
id,nameandargumentsfrom the deltas and ignoring other fields — drops the signature, and Gemini can then answer the next turn with a400. Such a400is relayed as any other provider error (see Error relay and redaction). Copyragmux_signaturealong with the other fields of each tool call. A signature that arrives in a stream chunk after the first call’s delta has already been sent is not relayed.
Known limit — tool-use tokens. On a tool round Gemini also reports
usageMetadata.toolUsePromptTokenCount, which Ragmux neither reads nor maps. Gemini’s
totalTokenCount is relayed as total_tokens as it arrives, so on those requests
prompt_tokens + completion_tokens can be less than total_tokens, and whatever the
difference covers is not priced: the cost estimate is built from the prompt and
completion counts alone. Google’s own references disagree on what totalTokenCount
sums — the REST reference says prompt + thoughts + candidates, the published
generativelanguage protobuf says prompt + candidates — and neither states whether
tool-use tokens are inside it, so Ragmux does not guess. Every other provider keeps
prompt_tokens + completion_tokens == total_tokens. Read a Gemini tool-round bill from
Google’s console, not from here.
Tool schema sanitising
Gemini’s parameters take an OpenAPI 3.0 subset and reject keywords they do not know,
rather than ignoring them. Every OpenAI strict-mode tool carries
"additionalProperties": false and most non-trivial ones carry $defs/$ref, so a
schema forwarded unchanged fails with Invalid JSON payload received. Unknown name "additionalProperties" — an error that names something the caller never wrote by hand.
Each schema is therefore rewritten before it is sent.
Kept: type, description, enum, items, properties, required, nullable,
anyOf, minimum, maximum, minItems, maxItems, minLength, maxLength,
pattern, and format only for enum/date-time on strings and
float/double/int32/int64 on numbers.
Kept means kept as the type Gemini’s Schema declares, not kept as written: description
and pattern must be strings, nullable a boolean, minimum/maximum doubles,
minItems/maxItems/minLength/maxLength non-negative whole numbers (they are
int64 there, so {"minItems":1.5} and {"maxLength":1e30} are dropped), and enum an
array of strings, because Schema.enum is repeated string. A value of any other shape
is dropped like an unknown keyword — {"type":"integer","enum":[1,2,3]} loses its enum,
and so does an enum on a node that is not a string, or on an anyOf node, rather than
travelling to Gemini as something it rejects.
Rewritten:
| From | To |
|---|---|
const: "x" |
enum: ["x"] |
const: 7, const: true, const: {…} |
type: "integer" / "boolean" / "object" — the value cannot be carried, but it still pins the type; dropped if the node already has one |
oneOf, allOf |
anyOf — an allOf whose branches are all objects is shallow-merged into the node instead |
type: ["string","null"] |
type: "string" + nullable: true; a wider union keeps its first non-null member |
exclusiveMinimum: N, exclusiveMaximum: N |
minimum: N, maximum: N |
$ref |
the definition from $defs/definitions, inlined |
A $ref is matched on its full JSON Pointer, so #/$defs/Foo, #/definitions/Foo and a
Foo nested under another node are three different definitions, and an external
https://…#/definitions/Foo resolves to none of them (it is elided, since that document
is never fetched). Properties and keywords are walked in sorted order, so the same schema
always sanitises to the same bytes.
Dropped: $schema, additionalProperties, title, default, examples, $comment,
patternProperties, not, if/then/else, unevaluated*, other format values and
any keyword not listed above. The dropped names are logged once per tool at debug with
provider=gemini.
$ref inlining is depth-capped at 8 and cycle-aware; a recursive or unresolvable
reference becomes {"type":"string","description":"(recursive schema elided)"} for a
cycle and "(unresolved schema reference elided)" for a definition this document does not
contain, which since references are matched on their full pointer is every external one. A
node that is not an object, or one nothing survived in, becomes {"type":"object"}, a node
that carried no type takes the one its surviving keywords imply (an anyOf node stays
untyped, because its branches carry the types) — that inference is a guess, and a lossy
one: a node carrying only minimum/maximum becomes number, so a field the caller
meant as an integer can come back with a fractional value, and one carrying only
minItems becomes string, keeping an array keyword on a scalar. Gemini rejects an
untyped node outright, so a guess beats the alternative; give a type to anything whose
shape matters. A declaration whose sanitised
schema has no properties left is sent without parameters — several model versions
reject {"type":"object","properties":{}}. Sanitising never fails; anything Gemini still
objects to comes back relayed verbatim.
This is lossy on purpose. A tool that relies on oneOf to discriminate between argument
shapes, on additionalProperties: false to forbid extra keys, or on a recursive $ref
behaves differently on Gemini than on OpenAI. If a tool depends on those, keep it on a
provider whose schema support is wider.
Ollama (native API)
The ollama type talks to Ollama’s own /api/chat (one JSON object, or newline-delimited
JSON objects when streaming) and /api/embed endpoints instead of the OpenAI shim, which
gives access to Ollama-only options:
temperature,top_p,max_tokens(→num_predict) andstopare placed inoptions.- Extra request fields are mapped:
keep_aliveis passed through as-is,num_ctxis added tooptions, and anoptionsobject is merged intooptions(any Ollama model option, e.g.{"options":{"num_gpu":1,"repeat_penalty":1.1}}). response_formatjson_objectsetsformat: "json";json_schemasends the schema asformat.- Tools use OpenAI’s
{type:"function", function:{…}}shape natively, sotoolsare forwarded; assistanttool_callsandtoolmessages (withtool_name) are translated both ways. Ollama sends no call ids, so the gateway generates them (call_…). - In streams each tool call arrives whole inside one NDJSON line, so it is relayed as
one
tool_callsdelta carrying the index, id, name and arguments together. This is a property of the protocol, not a gap: Ollama genuinely produces the arguments atomically, splitting them into fragments would invent framing that carries no information, and every OpenAI-shaped client accumulatesargumentsby string concatenation. Calls spread over several lines keep distinct indexes. - Images are sent to Ollama as inline base64. An
image_urlthat is not adata:URL is downloaded by the gateway first (see Images); withIMAGE_FETCH=falseit is rejected with400. - Finish reasons:
stop→stop,length→length; usage is built fromprompt_eval_count/eval_count. Ollama has no prompt cache, so no cache fields are reported and the shipped price table chargesollamamodels nothing.
Ollama itself is unauthenticated; if you set an api_key it is sent as
Authorization: Bearer …, which is useful behind an authenticating reverse proxy.
Ollama usually runs on a private address, which the gateway refuses by default. Add its
hostname to the allowlist in .env before creating the connection:
PRIVATE_UPSTREAM_ALLOWLIST=host.docker.internal,ollama # Docker host, or a Compose service named "ollama"
Then use base_url http://host.docker.internal:11434 (Ollama on the host) or
http://ollama:11434 (Ollama as a Compose service). ALLOW_PRIVATE_UPSTREAMS=true
allows every private host instead; see
Private upstreams.
To use Ollama’s OpenAI-compatible endpoint instead, create a custom_openai connection
with base_url ending in /v1 (for example http://host.docker.internal:11434/v1).
The native type is preferable for keep_alive, num_ctx and batch embeddings.
Images
A user message may carry OpenAI image parts:
{"type":"image_url","image_url":{"url":"…"}}, either an inline
data:image/png;base64,… URL or a remote one. Inline images are checked and then passed
to every vision-capable provider as they are. Remote ones depend on the provider:
| Provider | Remote image_url |
|---|---|
openai, custom_openai |
forwarded unchanged; the upstream fetches it |
anthropic |
forwarded as a source.type: url image block; Anthropic fetches it |
gemini |
Files API and gs:// URIs are forwarded as fileData; anything else is fetched by the gateway and inlined |
ollama |
fetched by the gateway and inlined — Ollama accepts nothing else |
An inline data: URL is held to the same whitelist a downloaded image is, and refused
with the gateway’s own 400 when it does not meet it — before anything is sent upstream:
- the URL must carry
;base64, sodata:image/png,%89PNG…is refused rather than forwarded as if it were encoded (thedata:scheme itself is matched case-insensitively); - the media type must be one this provider accepts (see the table below), so
data:text/html;base64,…no longer travels as an image; - the payload must decode as standard base64, padding included, and must not be
empty — an empty one used to vanish silently, leaving the client a
200for a message the model never saw an image in.
These used to pass through untouched and came back as a provider 400 that named nothing
the caller could act on. The check belongs to the translating adapters (gemini,
ollama, anthropic); the OpenAI-compatible types relay the request body verbatim by
design, so there the upstream is still the one that reads the part.
The accepted formats are the ones each upstream documents, and they are not the same set:
| Provider | Accepted image media types |
|---|---|
gemini |
image/png, image/jpeg, image/webp, image/heic, image/heif |
anthropic |
image/png, image/jpeg, image/gif, image/webp |
ollama |
image/png, image/jpeg, image/gif, image/webp |
One shared list would be wrong in both directions — it would refuse the HEIC Gemini takes and promise Anthropic a format its API rejects — so each connection is held to its own, inline and downloaded alike.
The gateway’s own fetch (IMAGE_FETCH, on by default) goes out over the same hardened
transport as provider calls but under a stricter policy: an image URL that resolves to
a loopback, link-local or private address is refused, and a redirect to another host — or
one that drops from https to http on the same host — is not followed.
The difference matters. ALLOW_PRIVATE_UPSTREAMS and PRIVATE_UPSTREAM_ALLOWLIST lift
the private-address check for provider calls — allowlisting a host is the documented way
to reach a local Ollama — but they do not apply to image fetching. A base_url is
typed by an editor; an image URL arrives from whoever holds an API key, so letting it
inherit that exemption would turn every allowlisted deployment into a way for a key holder
to probe the internal network. On top of that:
GETonly, withAccept: image/*, and onlyhttp/httpsURLs.- The response’s own
Content-Typedecides, against the same per-provider list an inline image is held to. The URL’s extension is never consulted. The cache is keyed on the URL alone, so a hit is re-checked against the asking provider’s list rather than trusted. IMAGE_FETCH_MAX_MBis enforced onContent-Lengthand again while reading, so a response that declares nothing (or lies) cannot exceed it.- Images are fetched one at a time, at most
IMAGE_FETCH_MAX_PER_REQUESTper chat request, each withinIMAGE_FETCH_TIMEOUT. - Across requests,
IMAGE_FETCH_MAX_CONCURRENTcaps the fetches the whole process runs at once and the connections it will hold to one host. A per-request limit bounds one request and nothing at all when many arrive together, which is what would make the gateway a useful amplifier for a target an API key chose. While another project is queueing, no single project takes more than half those slots: capping the process alone closed the outward problem and opened an inward one, where a few requests aimed at a slow image host filled every slot and an unrelated project waited out its whole timeout. - Takes, not holds. A slot already taken is never reclaimed. A project that filled the
gateway while nobody was waiting keeps those downloads running and can sit above its
half until they finish; what changes the moment somebody else queues is that it takes
nothing further until it is back inside the share. Cutting a running download short to
rebalance would throw away a fetch that is already part paid for, which is not what this
ceiling is for. So the share is a floor under everybody else rather than a ceiling on
one tenant: with nobody else waiting, a single project reaches the whole of
IMAGE_FETCH_MAX_CONCURRENT, which is what the setting says it is. - A fetch that cannot get a slot within two seconds (or
IMAGE_FETCH_TIMEOUT, if that is shorter — the wait is not separately configurable) is answered429withRetry-Afterandcode: "image_fetch_saturated". The gateway ran out, which is a resource limit rather than the client getting anything wrong, and deliberately not a5xx, which would report a fault and count against availability when nothing is broken.Retry-AfternamesIMAGE_FETCH_TIMEOUT, not the queue wait: a slot frees when a download finishes, so naming the wait sent clients that honour the header — the official SDKs do, for a429as much as for a5xx— straight back into the same full queue. The queue wait is its own budget, separate fromIMAGE_FETCH_TIMEOUT, which starts once a slot is in hand; otherwise the same saturation came back sometimes as a429and sometimes as a download timeout blaming the image host.
Anything the client got wrong — a bad scheme, a non-image response, an oversized image,
a non-2xx from the image host, too many images — is a 400 invalid_request_error naming
only the host of the offending URL, never the full URL (which may carry a signed query
string). A non-2xx is deliberately a 400 and not a 502: the client chose the host.
Fetched images are cached in memory (IMAGE_CACHE_ENTRIES, IMAGE_CACHE_MAX_MB,
IMAGE_CACHE_TTL) because a
multi-turn conversation resends the same image part on every turn. The cache is keyed on
the URL alone and is not written to disk, so an image whose content changes within the TTL
keeps serving the old bytes until it expires; a response carrying Cache-Control: no-store
is never cached.
Prompt caching
Providers cache a repeated prompt prefix and charge less for the cached part. Ragmux passes a client’s caching intent through where the provider has a field for it, and always reports back what the provider said it cached. Both sides feed the cost estimate shown on request logs.
Response side: one accounting for every provider
usage gains OpenAI’s breakdown objects, so the same fields mean the same thing on
every connection:
"usage": {"prompt_tokens": 250, "completion_tokens": 500, "total_tokens": 750,
"prompt_tokens_details": {"cached_tokens": 200, "cache_creation_tokens": 40},
"completion_tokens_details": {"reasoning_tokens": 30}}
prompt_tokens always includes the cached and freshly written parts: the mapping
guarantees that. prompt_tokens + completion_tokens == total_tokens is weaker — it is
how each adapter assembles the block from an upstream that reports consistently, and it
is not checked, so a Gemini tool round (see Tool calling) or any
upstream reporting a total that does not add up breaks it; the block is relayed as it
came. cache_creation_tokens has no OpenAI equivalent — OpenAI does not bill cache
writes, Anthropic does.
| Provider | What it reports | How it is mapped |
|---|---|---|
openai |
prompt_tokens_details.cached_tokens (already inside prompt_tokens) |
passed through unchanged |
deepseek |
prompt_cache_hit_tokens / prompt_cache_miss_tokens at the top level (their sum is prompt_tokens) |
the hit count is folded into cached_tokens; the top-level fields are still relayed |
anthropic |
input_tokens excluding cache_creation_input_tokens and cache_read_input_tokens |
all three are added up into prompt_tokens; reads become cached_tokens, writes cache_creation_tokens |
gemini |
usageMetadata.cachedContentTokenCount (already inside promptTokenCount) |
becomes cached_tokens; thoughtsTokenCount keeps counting as completion tokens and is also reported as reasoning_tokens |
ollama |
nothing; Ollama has no prompt cache | no cache fields |
Change from 0.3.x: for a cached Anthropic request,
prompt_tokensis now larger. Earlier releases reported Anthropic’sinput_tokensverbatim and so under-reported a cached prompt by the size of its cached prefix. Recorded token budgets and metrics for such requests move up accordingly.
Request side: cache_control
Anthropic takes a cache_control marker in three places, and Ragmux relays each of
them verbatim (the gateway never inspects or invents one):
| Where the client puts it | What is sent upstream |
|---|---|
on a content part: {"type":"text","text":"…","cache_control":{"type":"ephemeral"}} |
copied onto the matching Anthropic content block |
on a tool, at the top level or inside function |
copied onto the Anthropic tool object |
on a system/developer message’s content part |
the system field becomes an array of blocks, one per system message, carrying the marker |
With no cache_control anywhere, system stays the plain joined string it has always
been — a request that does not ask for caching is byte-identical to a 0.3.x one. When a
message has several marked parts the last marker wins, since Anthropic caches the prefix
up to and including the marked block.
RAG context drops the system marker. When a project’s RAG store injects passages, the
context block is prepended to the first system/developer message (in front of the
project system_prompt, which is itself prepended to the client’s own system text —
see Context injection), and the
cache_control on that message’s parts is removed. The block changes with every query
and comes first, so a breakpoint behind it would make Anthropic write a new cache entry
(billed above the plain input rate) on each request and never read one back. The parts
themselves and every other field are kept; markers on other messages, user content and
tools are untouched, and a project system_prompt on its own does not drop anything.
Placing the context after the client’s cached prefix is planned for v0.6.
OpenAI, DeepSeek and custom_openai: nothing is sent. Their caching is automatic and
server-side; there is no request field to emit, so Ragmux emits none. A cache_control a
client embeds rides along untouched (message content is relayed raw) and OpenAI ignores
it. Only the response side matters here — this is not a gap.
Gemini and Ollama: response side only. Gemini’s explicit caching needs a stateful
cachedContents resource created and referenced across requests, which does not fit a
stateless passthrough; its implicit caching happens automatically and is reported in
cachedContentTokenCount. Ollama has no prompt cache at all.
The project system prompt and the RAG context block the gateway injects are the stable
prefix most worth a breakpoint, but the gateway does not mark one: a project-level
cache_prompt switch needs a column and dashboard work of its own and is not in 0.4.
Model field echo
A project without chat routes ignores the client’s model for selection: the project’s
connection decides. The gateway replaces the value with the connection’s model name before
calling the provider and echoes the client’s original value back in the response (and in
every stream chunk) so SDKs that compare the field stay happy. When the client sends an
empty model, the connection’s model name is returned instead. GET /v1/models lists that
model name with owned_by set to the provider type.
Routing
A project can define named routes (see Routes). Each route has one to
eight targets: a connection, an optional model override, a weight and a priority.
Once a project has at least one chat route, the client’s model selects what runs:
- A route name matches exactly: the route’s targets are ordered (priority groups ascending, weighted random inside a group) and the first one is called.
- Otherwise the default connection’s model name selects the implicit route: the project’s own connection, as before.
- An empty
modelalso selects the implicit route. - Anything else is
404model_not_found.
The target’s model replaces the connection’s model name for the call; the provider,
pricing, request log and trace all see that effective model. The response model still
echoes the client’s value (for an empty model, the connection’s model name). In
request_logs, model_name is the effective model, requested_model is the client’s
value and route is the route name, empty for the implicit route. Routes of kind
embeddings or rerank do not switch chat routing on.
The project page has a route editor for all of this: a route’s kind (chat, embeddings
or rerank) and up to eight targets, each with its connection, optional model, weight
and priority.
Before your first chat route
Without chat routes the gateway answers whatever model string a client sends (it echoes
it back). The first chat route ends that: any string that is neither a route name nor the
default connection’s model name becomes 404 model_not_found. Check what your clients
actually send before you create it:
SELECT requested_model, count(*) FROM request_logs
WHERE project_id = <project id> GROUP BY requested_model ORDER BY count(*) DESC;
Create a route for every value that matters, or point those clients at the default
connection’s model name. Rolling back is deleting the route: with no chat routes left the
project echoes any model again.
Retry and fallback
A chat call that fails before the first byte reached the client can be repeated on the same target (retry) or handed to the route’s next target (fallback). The order of targets is the one Routing describes; a project without routes has one target, so only retries apply. What happens is decided by the kind of failure:
| Failure | Retry | Fallback |
|---|---|---|
| The request never left: DNS, refused connection, TLS handshake | yes | yes |
| Connection reset or cut after the request was sent | no | yes |
429, 503 (and Anthropic’s 529) with no Retry-After, or one of 2 s or less |
yes | yes |
429, 503 (and Anthropic’s 529) with Retry-After above 2 s |
no | yes |
500, 502, other 5xx |
no | yes |
504 or a timeout |
no | only if budget is left |
401, 403, 404 (the key or model is wrong for that target) |
no | yes |
400, 413, 422 and other 4xx, a client that went away |
no | no |
A reset after the request was sent is never retried on the same target: the provider may
already have processed it, and a repeat could be billed twice. A 529 from a provider
other than Anthropic is not retried either; it only hands the request to the next target.
A failure to download an image URL the client sent is the client’s, not the target’s: it
is answered as it is, with neither retry nor fallback.
Retries. Each target is retried up to UPSTREAM_MAX_RETRIES times (default 2, 0
turns them off). The wait before a retry is exponential with full jitter: a random time
below 250 ms, 500 ms, 1 s and so on, capped at 2 s. When the upstream sent Retry-After
of 2 s or less, the longer of the two is waited. A longer Retry-After is not waited for:
the next target takes over, and when there is none the client gets the error with
Retry-After set to the upstream’s value.
Budget. UPSTREAM_TIMEOUT is the budget of the whole request, not of one attempt.
Each attempt runs with what is left of it, and a wait that would use up the remainder is
skipped, so the attempts of a non-streaming request never add up to more than
UPSTREAM_TIMEOUT.
A streaming attempt is held to what is left of the budget only until its first chunk
arrives; once the stream is flowing UPSTREAM_TIMEOUT no longer limits it, and
STREAM_MAX_DURATION and STREAM_MAX_BYTES_MB do.
The rate-limit check, the system prompt and retrieval run once before the first attempt,
and usage is recorded once.
Streams. Retry and fallback apply only until the first chunk is sent, including an
upstream that answers 2xx and fails before it sends anything. After the response headers
went out the stream is never restarted or joined to another one: a cut stream ends the way
it did before.
Circuit breaker. A connection that keeps failing is taken out of the candidate list for a while, so retries and fallbacks do not spend the budget on a dead target. Every connection has its own breaker, held in memory by each replica; it is never written to the database, so replicas do not share it. A breaker has three states:
closed: calls pass and consecutive failures are counted. A success resets the count.open: reached afterBREAKER_FAILURE_THRESHOLDfailures in a row (default5,0turns breaking off). The connection is skipped forBREAKER_OPEN_DURATION(default30s), and a target that opens in the middle of a request is not retried any more.half-open: once the duration has passed, one call goes through as a probe. If it succeeds the breaker closes; if it fails the breaker opens again for a full duration. Other requests keep skipping the connection while the probe is in flight, and a probe that ends in a way that says nothing about the upstream, such as a client that went away, frees the slot for the next request.
What counts as a failure is what the table above calls an upstream fault: the request
never left (DNS, refused connection, TLS handshake), a reset after it was sent, 429,
503 and 529, other 5xx, and 504 or a timeout. 401, 403 and 404 do not count,
although they still hand the request to the next target, and neither do other 4xx or a
client that went away. Such an attempt leaves the count where it was. The same holds for
a stream: a client that leaves before or during it, or an upstream that fails after its
first byte (the client already got a 200), is neither a success nor a failure, so it
neither resets the count nor closes a half-open breaker.
An open connection is skipped when the route’s targets are walked, so a request goes
straight to the next healthy one. When every target of a route is open, the first one is
called exactly once, without retries; there is no immediate 503, so a recovered upstream
is noticed by the first request that reaches it. A project without routes has one target
and behaves as before. The state is per replica, so a fleet of N replicas sends up to N
probes to a bad upstream, and a restart starts every breaker closed. Opening and closing
are logged at warn and info level with the connection name, and
ragmux_upstream_breaker_state reports the state per connection
(Observability).
What the client sees. The error of the last attempt, in the format of
Error relay and redaction. A fallback that succeeds is
invisible apart from the request log: one row per request, attempt is the number of
upstream calls in total, fallback_from is the connection id of the first target whenever
the request moved past it (also when no target answered), and model_name is the model of
the last target tried. A target whose adapter cannot be built is skipped without counting
as a call; on a route with several targets that includes the first one. Each failed
attempt is logged at warn level with its connection id and class. Retries and fallbacks
are counted in ragmux_upstream_retries_total and ragmux_upstream_fallbacks_total
(Observability); a failure that is neither retried nor
handed on, such as a 400, reaches neither.
Error relay and redaction
Provider errors are relayed with the upstream message (cut to 512 characters) in the
OpenAI error envelope. A 4xx keeps its status, and so do 503 and 504, which tell
the client to come back later; Anthropic’s 529 overloaded means the same and becomes
503. Every other upstream 5xx becomes 502. The upstream’s
Retry-After (delay seconds or an HTTP date) is passed on unchanged; a value in neither
form is dropped. The gateway’s own 429s set their own Retry-After instead. Transport failures are never relayed verbatim:
they become 502 upstream_error with one of upstream unreachable, upstream TLS handshake failed, upstream returned a non-HTTP response, upstream redirect rejected: …, the private-address message from
Private upstreams, or upstream request failed;
timeouts become 504 timeout / upstream request timed out. The raw cause is logged at
warn level with the upstream host. Streaming responses end with an error after
STREAM_MAX_DURATION or STREAM_MAX_BYTES_MB.
Before an upstream message reaches a client, the request log or the gateway’s own log,
the connection’s own API key and anything matching Bearer …, Basic …, a JWT
(eyJ…), sk-…, sk-ant-…, AIza…, ya29.…, gsk_…, hf_…, xai-… or credentials
embedded in a URL (https://user:pass@host) is replaced by [redacted]. This covers
Anthropic stream error events and Ollama error bodies as well.