LUMEN
Lightweight Unified Model ENdpoint
A universal, self-hostable LLM gateway written in Rust. One OpenAI-compatible endpoint in front of many providers - for chat, embeddings, reranking and typed decisions alike. It is designed to be light, fast and sovereign: a single static binary, zero telemetry, and prompts that are never logged by default.
What’s here
- Getting started - installation, quickstart and configuration basics.
- Chat - completions, streaming, vision and tool calling.
- Embeddings - embeddings, batching and multimodal embeddings.
- Reranking - reranking.
- Decisions (typed answers) - Decisions
(TypeSafe, Perplexity, OpenAI, Cloudflare and Ollama models,
POST /v1/decisions). - Operations - token accounting & cost, metrics & dashboards, logging, usage log & multi-tenant metadata, keys, quotas & budgets, resilience tuning, deployment and upgrades.
- Examples - ready-made scenario configs.
- Reference - providers, error codes and the performance baseline.
- Architecture decisions - the ADRs that pin the design.
- Project - backlog and contributing.
The five pillars
Every trade-off is decided in this order:
- Performance - < 1 ms added latency p99, zero-copy streaming, ~15 MB RAM idle.
- Sovereignty - zero telemetry, prompts never logged by default, single binary.
- Robustness - propagated cancellation, backpressure, DB off the request path.
- Multi-capability - chat + embeddings + rerank + typed decisions are first-class citizens.
- Token observability - every request of every capability produces a token
count: upstream usage when reported, otherwise a local estimate flagged
estimated. Never a silent zero. See token accounting & cost.
Want to contribute? Start with the contribution guide.
Installation
LUMEN ships as a single static binary. Pick one of three ways to get it: Docker, a prebuilt binary from a GitHub release, or a build from source.
Docker
docker run -p 8080:8080 \
-v ./config.toml:/config.toml \
-e OPENAI_API_KEY=sk-... \
ghcr.io/qdequele/lumen:latest
The image sets LUMEN_SERVER__HOST=0.0.0.0 for you, so the server binds to
all interfaces inside the container. The image is multi-arch: linux/amd64
and linux/arm64.
Prebuilt binary
Static musl binaries for x86_64-unknown-linux-musl and
aarch64-unknown-linux-musl are attached to every GitHub release cut from a
v* tag, each with a .sha256 checksum file alongside it. Verify a download
before unpacking:
shasum -a 256 -c lumen-x86_64-unknown-linux-musl.tar.gz.sha256
From source
Needs a recent stable Rust toolchain (MSRV 1.94, per Cargo.toml and checked
in CI against the committed Cargo.lock):
cargo build --release -p server --bin lumen
The binary lands at target/release/lumen. Run it with:
lumen --config config.toml
Validate a config without booting
lumen --check-config [--config <PATH>] validates a config file the same way
the server does at boot (parsing, semantic validation and provider registry
construction) and exits: 0 if valid, non-zero otherwise. It binds no
listener, opens no database, and contacts no provider, so it is safe to run
in a CI or deploy pipeline ahead of a real boot:
lumen --check-config --config config.toml
Next
Continue to the Quickstart, or browse the fully commented
config.example.toml
on GitHub.
Quickstart
Zero to a successful chat + embed + rerank request.
1. Minimal config
The ids below (gpt-4o, text-embedding-3-small, rerank-english) are the
same ones used throughout
config.example.toml.
This minimal file needs an OpenAI key (chat + embeddings) and a Cohere key
(rerank).
# config.toml - minimal quickstart config
[[providers]]
name = "openai"
kind = "openai"
api_key_env = "OPENAI_API_KEY"
[[providers.models]]
id = "gpt-4o"
upstream_id = "gpt-4o-2024-08-06"
capabilities = ["chat"]
[[providers.models]]
id = "text-embedding-3-small"
capabilities = ["embed"]
[[providers]]
name = "cohere"
kind = "cohere"
api_key_env = "COHERE_API_KEY"
[[providers.models]]
id = "rerank-english"
upstream_id = "rerank-v3.5"
capabilities = ["rerank"]
2. Run
Docker (the released image; sets LUMEN_SERVER__HOST=0.0.0.0 for you):
docker run -p 8080:8080 \
-v ./config.toml:/config.toml \
-e OPENAI_API_KEY=sk-... \
-e COHERE_API_KEY=... \
ghcr.io/qdequele/lumen:latest
From source (needs a recent stable Rust toolchain):
export OPENAI_API_KEY=sk-...
export COHERE_API_KEY=...
cargo run -p server -- --config config.toml
By default auth is off, so these requests need no Authorization header.
Providers whose API-key env var is unset are only rejected when a request
actually routes to them, so a partial set of keys is fine.
If you turn auth on ([auth] enabled = true), bootstrap your first virtual
key with lumen keys create --name bootstrap - it runs offline against the
auth database, before the server is ever started. See
Keys, quotas & budgets.
3. Chat
curl -s http://localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-d '{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Say hello in one word."}]
}'
The response is the OpenAI chat completion envelope, including a usage
object with the token count.
4. Embeddings
curl -s http://localhost:8080/v1/embeddings \
-H 'content-type: application/json' \
-d '{
"model": "text-embedding-3-small",
"input": ["the quick brown fox", "a lazy dog"]
}'
The response carries data[].embedding for each input, plus a usage
object.
5. Rerank
curl -s http://localhost:8080/v1/rerank \
-H 'content-type: application/json' \
-d '{
"model": "rerank-english",
"query": "What is the capital of France?",
"documents": ["Paris is the capital of France.", "Berlin is in Germany."],
"top_n": 2
}'
Results come back sorted by descending relevance_score. documents must be
non-empty: an empty list is rejected with LM-2010.
Next steps
Configuration basics
Everything is one TOML file, plus LUMEN_* environment variable overrides
that use __ for nesting, e.g. LUMEN_SERVER__PORT=9090. In the TOML file
itself, top-level keys must appear before any [table] header. The
exhaustively commented reference is
config.example.toml
on GitHub.
Section tour
log_format - "pretty" (human-readable, default) or "json"
(production).
[server] - the HTTP server: host (bind address; use "0.0.0.0" in a
container) and port (must not be 0), body_limit (max request body in
bytes), first_token_timeout_ms (how long to wait for the upstream’s first
sign of life before failing with LM-3011; for streaming this is time to the
first SSE frame, for non-streaming it is the whole upstream call), and
sse_heartbeat_ms (idle interval after which a : ping SSE comment keeps
proxies from reaping a silent stream).
[auth] - virtual keys, hard budgets, quotas and the usage log.
Disabled by default, in which case the gateway is an open proxy with no
database at all. See Keys & budgets.
[telemetry] - which x-lumen-metadata keys become Prometheus labels
on the token counters. See Usage log.
[resilience] - retries, circuit breaker, timeouts and
health checks. Every value is the built-in default and the whole section is
optional; see config.example.toml for the full set. Details in
Resilience.
[[providers]] / [[providers.models]] - one [[providers]] block per
upstream (id, upstream_id, capabilities, modalities, costs,
and an optional release_date ISO date that
GET /v1/models returns as release_date and as the OpenAI integer
created, so clients can sort models by release). See Providers for the full
provider matrix and per-provider notes. These are the foundation models:
plain upstream bindings. Fallback, split and conditional routing no longer
live on them (the per-model fallbacks field was removed).
[[virtual_models]] - public ids that carry routing logic over your
foundation models: single, fallback, split and switch strategies,
overrides, chat presets, and Jev as a reranker. A virtual model has an id,
one capability, a strategy, targets, and optionally fallback_on,
preset, a description shown on GET /v1/models (where it appears with
"virtual": true) and listed = false to hide it from that list while it
stays callable. Foundation and virtual ids share one namespace. See
Virtual models. An older config with fallbacks is
converted by lumen config migrate.
[image_fetch] - server-side fetching of remote image URLs for
multimodal input. See Multimodal input.
API keys
API keys are never written in the config. A provider references the name
of the environment variable that holds its key, via api_key_env.
Hot reload
A SIGHUP, a file watch (file mode), an admin config write, or an admin
provider-key rotation triggers a reload: the new config is validated, then
the provider registry, price table, resilience policy, the runtime-safe
[auth] knobs (flush_interval_ms, retention_days), the webhook policy,
the image-fetch policy and the tokenizer are atomically swapped.
The boot layer is read once at startup and needs a restart:
[server] (bind address, body limit, stream timeouts), log_format,
[telemetry] (its label allowlist becomes the Prometheus label set),
auth.enabled, auth.db_path, the usage-log channel knobs
(usage_channel_capacity, usage_batch_max, usage_flush_ms),
[usage_events] (billing usage events for the Meilisearch Lab, ADR 015;
also set by LAB_URL and LAB_INSTANCE_ID), and
config_source. Details in
Deployment.
Where the config lives
By default the whole config is the one TOML file the gateway was started
with (config_source = "file"). Setting config_source = "db" moves
everything outside the boot layer into the auth database instead, so the
gateway can be configured entirely through the admin API with no config
file to manage beyond a few boot keys. See
Config source modes.
Viewing the live config over the admin API
GET /admin/config returns the current config document, verbatim: in file
mode, the config file the gateway booted from; in db mode, the dynamic
document stored in the database (an empty string before the first write).
It requires the master key AND auth.enabled = true: the whole /admin/*
router is only mounted when auth is on, so on a default deployment (where
[auth] is disabled) this route does not exist at all and answers 404
rather than 401.
{
"config": "<raw toml, byte for byte>",
"hash": "<64 hex chars, BLAKE3>",
"key_sources": { "openai": "env", "cohere": "stored", "local": "not_required" }
}
config is the document’s exact bytes, never a re-serialisation of the
merged in-memory config: Config::load overlays LUMEN_* environment
variables on top of it, and showing that merged view would make environment
overrides look like document content, and a later write would bake them in
permanently. hash is a BLAKE3 content hash of those same bytes, meant to
be echoed as If-Match on the PUT /admin/config that applies a new one
(ADR 010, ADR 012), so two operators editing at once cannot silently
clobber each other.
To read or change one provider or one section without handling the whole
document, use the granular endpoints (/admin/config/providers/{name},
/admin/config/{section}) described in
Config source modes.
key_sources maps each provider in the dynamic document (the file in file
mode, the stored document in db mode) to where its API key resolves
from, using the same precedence as startup and hot reload: env (the
provider’s api_key_env variable is set in the gateway’s environment, which
wins), stored (a key stored via PUT /admin/provider-keys/{name}
back-fills an unset variable), missing (no key anywhere and the provider
kind needs one), or not_required (a keyless kind such as Ollama, TEI,
vLLM, NIM or Bedrock). It reports the source only, never the key. If the
document does not parse, key_sources is an empty object; config is still
returned verbatim.
Applying a new config over the admin API
PUT /admin/config is the highest-privilege route in the gateway: it can
repoint any provider’s base_url (or add a new provider entirely) and
thereby redirect customer traffic to a different upstream. The granular
provider endpoints can do the same. Master key required, same as every
other /admin/* route, and likewise only mounted when auth.enabled = true: with auth off the route is absent and returns 404, not 401.
The If-Match contract:
- Send the submitted document as the raw request body (
Content-Typeis irrelevant; the body is treated as the document’s new contents). - Send the
hashfrom a priorGET /admin/configas theIf-Matchheader. A missingIf-Matchheader is rejected with400(LM-1001) - the request is malformed like any other missing-required-input case. - If
If-Matchdoes not equal the current document’s hash (someone else applied a change since you last read it, or in file mode someone edited the file directly), the request is rejected with412(LM-1004) and nothing is written.GET /admin/configagain to see what changed, then re-apply against the fresh hash.
What happens on a successful apply:
- The candidate is validated exactly like a hot reload would: parsed,
merged with
LUMEN_*env vars, and used to build a candidate provider registry. A parse failure or a registry-build failure (e.g. a provider missing a requiredbase_url) is rejected with400(LM-1001) and nothing is written. The error message names the offending field or setting (e.g."server.first_token_timeout_ms must not be 0") but never a filesystem path, which is logged server-side instead. - A candidate that changes a restart-only (boot-layer) setting is rejected
with
400(LM-1001), naming it: the route only accepts what it can apply immediately. In db mode, a candidate that carries any boot-layer key at all is rejected the same way. - The document is persisted atomically. File mode: the current file is
copied to a
.baksibling (e.g.lumen.toml.bak) - one generation of history, enough to revert a bad apply by hand - and the new document is renamed into place. DB mode: a new row is added toconfig_versions(the newest 50 are kept). - The hot-reload trigger fires, so the new config takes effect without a
restart (see Hot reload). Every setting this route
accepts is live once it returns
204.
A rejected apply (400 or 412) is guaranteed to leave the stored
document byte-for-byte unchanged and to leave no temporary file behind.
Concurrent PUTs are serialised gateway-side, so two operators racing the
same pre-apply hash can never both land: exactly one wins, and the other
sees a 412 for a hash that moved out from under it, never a silently
corrupted mix of the two documents.
Security note: the master key is equivalent to host filesystem access.
api_key_env accepts ANY environment variable name, not just ones a
provider convention would suggest. Because PUT /admin/config lets a
master-key holder add a provider with an attacker-controlled base_url and
api_key_env pointed at any variable present in the gateway’s own process
environment - LUMEN_MASTER_KEY itself, or any cloud credential the process
happens to carry - a single crafted config plus one request to that
provider’s model returns the named secret back to the caller as a Bearer
token. Before remote config apply existed, reaching this required
filesystem write access on the gateway host; with this route, the master
key alone is enough. In practice: holding the master key is now equivalent
to filesystem write access on the gateway host, plus read access to its
entire process environment. Mitigate with a separate master key per
gateway, exposing the admin surface on a private network only, and using the
read-only config mount opt-out (see
Deployment) on any file-mode
gateway that does not need remote apply. In db mode the admin API is the
only way to change the config, so there is no equivalent opt-out.
Validate before you boot
Run lumen --check-config --config config.toml to validate a config file
without starting the server. See Installation.
Chat completions
POST /v1/chat/completions speaks the OpenAI request and response format.
The model field is one of your configured model ids (the id in a
[[providers.models]] block, not necessarily the upstream’s own model name -
see Providers for aliasing with upstream_id).
Request
curl -s http://localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-d '{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Say hello in one word."}]
}'
Response
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1731000000,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Hello!" },
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 12, "completion_tokens": 3, "total_tokens": 15 }
}
Unknown fields pass through
Request fields LUMEN does not model as a typed struct field (tools,
response_format, provider-specific extensions, …) are preserved verbatim
and forwarded to the upstream untouched, rather than stripped. Provider-specific
parameters keep working without waiting on a LUMEN release to add them by
name.
Verbatim passthrough applies to OpenAI-compatible providers. On the
translated kinds (anthropic, google, vertex_ai, bedrock,
cohere), response_format, seed, logprobs, top_logprobs,
logit_bias and parallel_tool_calls
are mapped natively where the upstream supports them and otherwise dropped
with a debug log - or rejected up front with LM-1001 when the provider sets
strict = true. See the
chat-extras matrix in Providers.
Routing and request errors
| Code | HTTP | When |
|---|---|---|
LM-2001 | 404 | The requested model id was not found. |
LM-2002 | 400 | The model exists but does not serve the chat capability. |
LM-1001 | 400 | Malformed or invalid request body. |
LM-1002 | 413 | Request body exceeded the configured size limit. |
Full taxonomy in Error codes.
Fallbacks
If the requested id is a virtual model with a
fallback strategy and the primary target fails, the request fails over
automatically. The foundation model that actually served the request
(primary or a fallback) is reported in the x-lumen-model-used response
header, and the path taken in x-lumen-route. A virtual model can also carry
a chat preset (a stored system prompt), which is applied to the request before
routing. See Resilience.
Providers
Which provider kinds serve chat and their setup is in
Providers.
Streaming
Add "stream": true to a /v1/chat/completions request and the response
becomes text/event-stream: a series of data: {...} frames (each a
chat.completion.chunk), terminated by a literal data: [DONE].
curl -s http://localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-d '{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Count to 3."}],
"stream": true
}'
data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1731000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"1"},"finish_reason":null}]}
data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1731000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":", 2, 3"},"finish_reason":null}]}
data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1731000000,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
Passthrough design
When the upstream already speaks OpenAI-shaped SSE (the OpenAI-family kinds,
vllm), the gateway forwards the upstream bytes to the client verbatim - no
per-chunk deserialize/re-serialize round trip. Providers that translate a
foreign event schema (anthropic, google) build typed chunks instead,
which is not zero-copy but is required because they must translate the
schema anyway. See ADR 004.
Heartbeats
If the stream goes idle for longer than sse_heartbeat_ms
([server] in config.example.toml), the gateway injects a : ping SSE
comment to keep intermediate proxies from reaping the connection as silent.
Commitment: the first content frame, not the open
Retries and fallback stay live past the upstream opening the stream (2xx +
headers): after opening, the gateway peeks the first frame before committing
to the client. An upstream that opens 200 then errors, or closes, before
delivering any content frame is a pre-commit failure - the gateway retries
and falls over per the resilience policy (and penalises that provider’s
circuit breaker) exactly like an open failure, instead of surfacing a
terminal SSE error frame. A silent open (no bytes at all) still fails over
via the first_token_timeout_ms bound on the peek. Only once the first
content frame is forwarded does the request commit; from that point the
guards below own the rest and nothing retries. See ADR 005 (first-frame-peek
amendment).
Guards (post-commit)
- No first content frame within
first_token_timeout_ms([server]), across every retry/fallback attempt -> the request fails withLM-3011(504). - The upstream connection dies mid-stream (after commit) without a
[DONE]terminator, or every link in the fallback chain fails to deliver a content frame at all -> the client receives a terminal SSE error frame carryingLM-3010(502).
Both are documented in Error codes.
Client disconnect
If the client disconnects mid-stream, the upstream call is aborted and the
request’s accounting settles at HTTP 499 (LM-6001, client_cancelled) -
never counted as a 5xx internal error. See
ADR 006 and
Error codes.
Usage in streams
The final chunk carries usage when the upstream reports it there;
otherwise the gateway falls back to a local estimate flagged
"estimated": true. Every request produces a token count, never a silent
zero - see Token accounting.
Vision (image input)
POST /v1/chat/completions accepts OpenAI’s content-parts message shape, so
a user message can carry text and image parts in one array. It is opt-in per
model: a model only accepts image parts once its config declares the image
modality (modalities = ["text", "image"]; the default is ["text"]).
GET /v1/models reflects the opt-in back as "modalities": ["text","image"]
per model.
{
"model": "gpt-4o",
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "What is this?" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0KG..." } },
{ "type": "image_url", "image_url": { "url": "https://example.com/cat.png" } }
]
}]
}
image_url.url is either a data:<media-type>;base64,<payload> inline URI
or a remote http(s) URL.
Pre-flight
Sending an image part to a model whose modalities lack "image" is
rejected with LM-2003 (400) before any upstream call. The check inspects
the whole fallback chain, not just the primary, so a fallback missing the
image modality is caught up front too.
Per-provider handling
OpenAI-family kinds, vllm and azure forward image parts verbatim - both
data: URIs and remote URLs. anthropic and cohere translate both forms
into their own schema (both upstreams fetch a remote URL themselves).
google, vertex_ai and bedrock translate only inline data:
URIs into their own schema; a remote http(s) URL routed to any of the
three is rejected pre-flight with LM-2004 (400), since none of them
fetches a URL itself and the gateway never fetches a chat image URL on the
caller’s behalf (an SSRF vector it deliberately avoids). Full per-kind table
in Providers - Vision.
Provider-native sources
Two provider-native reference forms are recognised in image_url.url, for
callers whose images are already uploaded to the provider: an Anthropic
Files API reference (anthropic-file:<file_id>) and a Gemini-native
reference (gs://bucket/object, or a Gemini Files API URI under
https://generativelanguage.googleapis.com/). A reference routed to a model
whose primary provider does not match the reference’s own provider is
rejected pre-flight with LM-2008 (400) instead of surfacing as a confusing
upstream failure. Details, including the gs:// / Developer API caveat, are
in Providers - Vision.
Token accounting
Upstream-reported usage is authoritative and already folds in image
tokens. When an upstream reports no usage at all, the local estimation
fallback counts each image content part with a flat per-image heuristic (85
tokens at "detail": "low", 765 tokens otherwise) rather than counting it as
zero, and the response is still flagged "estimated": true. See
Token accounting.
Tool calling
/v1/chat/completions accepts OpenAI’s tools request field and returns
tool_calls on the response message, for every chat provider: passed
through untouched for the OpenAI-family kinds, vllm and azure (via the
same unknown-field passthrough that carries tools and tool_choice, see
Chat completions), translated to and from the provider’s
own schema for anthropic (tool_use content blocks), google/vertex_ai
(tools[].functionDeclarations, toolConfig.functionCallingConfig),
bedrock (toolConfig/toolUse/toolResult on the Converse API) and
cohere (mostly a field rename - OpenAI-shaped tool_calls pass through
largely unchanged - with tool_choice collapsing to Cohere’s
REQUIRED/NONE strings; forcing one named tool has no v2 equivalent and
falls back to auto).
Two-leg flow
1. Request with tools:
{
"model": "gpt-4o",
"messages": [{ "role": "user", "content": "What is the weather in Paris?" }],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"parameters": { "type": "object", "properties": { "city": { "type": "string" } } }
}
}]
}
2. Response with a tool call (finish_reason: "tool_calls"):
{
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" }
}]
},
"finish_reason": "tool_calls"
}]
}
3. Follow-up request, appending the assistant’s tool call and a tool
role message with the result:
{
"model": "gpt-4o",
"messages": [
{ "role": "user", "content": "What is the weather in Paris?" },
{ "role": "assistant", "content": null, "tool_calls": [ /* as above */ ] },
{ "role": "tool", "tool_call_id": "call_1", "content": "15C, cloudy" }
]
}
The model grounds its final answer in the tool result and returns a normal
finish_reason: "stop" message.
Streaming
With "stream": true, tool_calls arrive as incremental deltas in OpenAI
format (id and name first, then argument fragments), same as OpenAI’s own
streaming tool-call shape. See Streaming.
Coverage
Translation is implemented for anthropic, google, vertex_ai, bedrock
and cohere; the OpenAI-family kinds, vllm and azure pass
tools/tool_choice through untouched since they already speak (or
near-passthrough, for azure) the OpenAI shape. Provider setup and the full
capability matrix are in Providers.
Embeddings
POST /v1/embeddings speaks the OpenAI request and response format. The
model field is one of your configured model ids (the id in a
[[providers.models]] block - see Providers).
Request
curl -s http://localhost:8080/v1/embeddings \
-H 'content-type: application/json' \
-d '{
"model": "text-embedding-3-small",
"input": ["the quick brown fox", "a lazy dog"]
}'
Response
{
"object": "list",
"data": [
{ "object": "embedding", "index": 0, "embedding": [0.0023, -0.009, ...] },
{ "object": "embedding", "index": 1, "embedding": [0.0071, 0.014, ...] }
],
"model": "text-embedding-3-small",
"usage": { "prompt_tokens": 8, "total_tokens": 8 }
}
encoding_format
"encoding_format": "float" (the default) returns each embedding as a
JSON float array. "base64" returns it as an OpenAI-style base64 string of
little-endian f32 bytes instead. This is purely an output concern: LUMEN
always holds vectors as floats internally, so it works uniformly even for
providers with no native encoding_format, such as Ollama and TEI.
Accepted input shapes
| Shape | Example | Notes |
|---|---|---|
| Single string | "input": "hi" | One item. |
| Array of strings | "input": ["a", "b"] | A batch, embedded and returned in order. |
| Pre-tokenized token-id array | "input": [1, 2, 3] | One item, counted as one embedding (OpenAI semantics). |
| Batch of token-id arrays | "input": [[1, 2], [3, 4]] | Each inner array is one item. |
| Content-part array(s) | "input": [[{"type": "text", ...}, {"type": "image_url", ...}], "plain text"] | Multimodal: each item is a string or an array of text/image parts. See Multimodal embeddings. |
Pre-tokenized shapes are rejected on providers that cannot consume them (see
below). Content-part shapes require a model that opts into the image
modality (see Multimodal embeddings).
Pre-tokenized input on text-only providers
Token-id array input ([1,2,3] or [[1,2],[3,4]]) passes through natively
on OpenAI-compatible providers. Providers whose upstream API only accepts
text - cohere, tei, ollama, jina, voyage, mistral - reject it
before any upstream call with LM-1001 (400), naming the provider and the
rejected shape. See Error codes.
Unknown fields and input_type (Cohere)
Unlike /v1/chat/completions, unknown request fields on /v1/embeddings are
captured but never re-serialized into the outgoing provider body - they stop
at the gateway rather than being forwarded, since a strict OpenAI-compatible
upstream may reject fields it does not recognize. The one field the gateway
itself reads is input_type, consumed only by the Cohere translation to
override Cohere’s query-vs-document intent (search_query,
search_document, classification, clustering; defaults to
search_document). An unrecognized input_type is rejected with LM-1001
before any upstream call. See Providers - cohere.
Strict mode
By default a provider silently drops a request field it cannot honor (for
example dimensions sent to Ollama, which has no such parameter). Setting
strict = true on that provider’s [[providers]] block makes it reject
such a request instead, with LM-1001 naming the field. encoding_format
is always handled at the response edge and is never affected by strict.
Providers
Which provider kinds serve embed, and their batch limits, are in
Providers.
Batching
An embed request with more inputs than the target provider’s batch limit is
split into sub-batches, run with bounded concurrency, and reassembled in the
original input order before the response is returned. This is invisible to
the client: one request in, one response out, data[].index numbered
against the original input regardless of how it was split upstream.
Where limits come from
Every provider kind has a built-in embed batch limit. The native kinds
(openai, mistral, cohere, jina, voyage, tei, ollama, azure)
each have their own limit; the OpenAI-compatible hosts all share a
2048-input limit.
The exact numbers are in the Providers matrix, in the
native-kinds table and in the
OpenAI-compatible hosts section.
Usage
A batched request still produces exactly one usage object: prompt_tokens
and total_tokens are summed across every sub-batch response before the
client sees them.
Multimodal embeddings
An embed model opts into image input by declaring the image modality:
[[providers.models]]
id = "embed-multilingual"
capabilities = ["embed"]
modalities = ["text", "image"]
Once opted in, input items may be a bare string or an array of content
parts ({"type":"text",...} / {"type":"image_url",...}), and both kinds
of item can be mixed within one batch. Image input sent to a model without
the image modality is rejected with LM-2003 (400) before any upstream
call. See Embeddings for the other accepted input shapes.
Image sources
image_url.url is a data:<media-type>;base64,<payload> inline URI or a
remote http(s) URL.
data:URIs always work - the bytes travel in the request body, no fetch required.- Remote
http(s)URLs require[image_fetch] enabled = true. With fetching disabled (the default), a remote URL is rejected withLM-2005(400) - pass adata:URI instead, or enable fetching.
The guarded fetch
When [image_fetch] is enabled, a remote URL is fetched server-side,
base64-encoded, and inlined before being sent to the provider. The fetch is
guarded:
- The private/loopback/link-local IP block is non-configurable (always on).
- The connection is pinned to the resolved address that was vetted against that block, so a DNS answer cannot rebind to a different address after the check (DNS-rebinding safe).
- Scheme, host, and URL-prefix allowlists (
allowed_schemes,allowed_hosts,allowed_url_prefixes). - A streamed size cap (
max_bytes) and a fetch timeout (timeout_ms). - Only
image/*response content types are accepted. - Redirects are re-validated against the same guards, not just the original URL.
- A per-request cap on the number of images fetched.
A request rejected by any of these guards fails with LM-2006 (400); the
specific reason is logged server-side only, never returned to the client. A
URL that passes the guards but fails at the remote host (network error,
timeout, or error status) fails with LM-2007 (502). See
Error codes.
Config reference
# ---------------------------------------------------------------------------
# Multimodal image fetching (M9) - OFF by default.
#
# When enabled, a remote http(s) image URL in a /v1/embeddings content-parts
# request is fetched server-side, base64-encoded, and inlined as a data: URI
# before being sent to the provider. Disabled → such a URL is rejected with
# LM-2005 (clients must pass a data: URI themselves).
#
# Guards (always on when enabled): the private/loopback/link-local IP block is
# non-configurable; the connection is pinned to the vetted resolved address
# (DNS-rebinding safe); downloads are capped and time-bounded; only image/*
# content types are accepted. Restrict `allowed_hosts` / `allowed_url_prefixes`
# to your own asset origins in production - leaving both empty with fetching on
# logs a startup warning.
# ---------------------------------------------------------------------------
[image_fetch]
enabled = false
max_bytes = 10485760 # 10 MiB per image
timeout_ms = 5000 # per-fetch timeout
allowed_schemes = ["https"] # add "http" to allow plaintext
allowed_hosts = [] # e.g. ["cdn.example.com", ".mycompany.com"]; empty = any public host
allowed_url_prefixes = [] # e.g. ["https://cdn.example.com/images/"]; empty = no prefix restriction
Per-provider semantics
Not every multimodal provider embeds mixed input the same way: Cohere (embed-v4) and Voyage embed one combined text+image vector per item, while Jina embeds one modality per item - a mixed item is sent as its image, and its caption text is not combined into the vector. See Providers for the full per-kind notes.
Accounting
Every fetched or inlined image is counted: lumen_media_total and
lumen_media_bytes_total in Prometheus, and matching columns in the
usage_log table. See Metrics.
Reranking
POST /v1/rerank speaks the Cohere request and response format: query,
documents, top_n. The model field is one of your configured model ids
(the id in a [[providers.models]] block - see Providers).
Request
curl -s http://localhost:8080/v1/rerank \
-H 'content-type: application/json' \
-d '{
"model": "rerank-english",
"query": "What is the capital of France?",
"documents": ["Paris is the capital of France.", "Berlin is in Germany."],
"top_n": 2
}'
Response
{
"results": [
{ "index": 0, "relevance_score": 0.98 },
{ "index": 1, "relevance_score": 0.02 }
],
"usage": { "search_units": 1, "total_tokens": 42, "tokens_estimated": true }
}
Results come back sorted by descending relevance_score. Each result’s
index points back to the position of that document in the request’s
documents array, not the sorted position.
Empty documents
documents must be non-empty. An empty list is rejected before any upstream
call with LM-2010 (400). See Error codes.
Billing: search units and tokens
Rerank billing units vary by upstream: Cohere and Pinecone bill in search
units (one unit is approximately one query over up to 100 documents),
while Jina and Voyage bill in tokens. Set cost_per_1k_searches on a
model’s [[providers.models]] block to price the search-unit case:
[[providers.models]]
id = "rerank-english"
upstream_id = "rerank-v3.5"
capabilities = ["rerank"]
cost_per_1k_searches = 2.0
Search units are counted on the lumen_rerank_search_units_total{model, provider} Prometheus counter for every request, upstream-reported when
available, otherwise a gateway estimate (usage.estimated: true).
Independently of the billing unit, every /v1/rerank response also carries a
usage.total_tokens count (ADR 003), for uniform token observability across
capabilities: Jina and Voyage surface their upstream-reported token count
unflagged; every other provider (Cohere, TEI, Pinecone, …) gets a
gateway-derived query + documents heuristic flagged
"tokens_estimated": true. This token count also feeds
lumen_tokens_total{capability="rerank",...} and usage_log, alongside the
search-unit accounting - see
Token accounting & cost.
One model, two capabilities
A single model id can serve both embed and rerank if the underlying
upstream model supports both, for example Cohere’s embed-v4.0:
[[providers.models]]
id = "embed-multilingual"
upstream_id = "embed-v4.0"
capabilities = ["embed", "rerank"]
Cross-vendor fallback
Like any capability, rerank can fall back across different provider kinds, through a virtual model. A three-hop chain across Cohere, Jina and Voyage survives any single vendor outage:
[[providers.models]]
id = "cohere/rerank-english"
upstream_id = "rerank-v3.5"
capabilities = ["rerank"]
[[virtual_models]]
id = "rerank-english"
capability = "rerank"
strategy = "fallback"
targets = [{ model = "cohere/rerank-english" }, { model = "jina-rerank" }, { model = "voyage-rerank" }]
The foundation model that actually served the request (primary or a fallback)
is reported in the x-lumen-model-used response header, and the path taken in
x-lumen-route. See Resilience.
Decision models as rerankers (Jev, Perplexity, OpenAI, Ollama, Cloudflare)
A rerank virtual model
whose target carries a remap is served by a decision model. The target
points at any foundation model that declares decisions (typesafe,
perplexity, openai, ollama or cloudflare kinds; a typesafe model can
no longer declare rerank itself). Each request becomes decision calls whose
state is {"query": ...} and whose questions, by default, are one
predicate (alias noul) per document, the document carried in structured
instructions; the predicate’s calibrated probability that the document is
relevant is the relevance_score. Jev receives the same bytes as before the
rename; an OpenAI target receives the structured instructions as compact JSON
text. The examples below use Jev.
[[providers]]
name = "typesafe"
kind = "typesafe"
api_key_env = "TYPESAFE_API_KEY"
[[providers.models]]
id = "jev"
upstream_id = "jev-latest"
capabilities = ["decisions"]
cost_per_1m_input = 0.042
# Optional remap fields: the question asked about every document. Unset
# fields default to a generic relevance question.
[[virtual_models]]
id = "jev-rerank"
capability = "rerank"
strategy = "single"
[[virtual_models.targets]]
model = "jev"
[virtual_models.targets.remap]
strategy = "predicate"
instructions = "Could `document` be the precedent cited in the query?"
criteria.true = "The document states the specific rule the query cites."
criteria.false = "The document is only on a similar topic."
- Besides
predicate, the remap strategies arescore,composite(weighted criteria) andchoice, and a staticcontextcan be added; see Virtual models.strategy = "noul"is still accepted as an alias ofpredicate, andlumen config migraterewrites it. - Clients call plain
/v1/rerank; ordering,top_n,return_documents,rank_fieldsand fallbacks (afallbackvirtual model whose second target iscohere/rerank-english, say) work as for any reranker. - Documents are packed into as few upstream calls as the target allows. The
default is at most 100 documents and about 48k estimated tokens per call,
4 calls in flight. A target’s own limits tighten that: Cloudflare packs at
most 64 documents per call; Ollama packs about 16k tokens per call with one
call in flight (a local GPU). A listwise
choiceover more documents than the target’s option limit (Ollama 26, otherwise 255) isLM-1001. A document longer than about 4,096 tokens is truncated first, like Cohere’s defaultmax_tokens_per_doc. - Refusals: OpenAI can refuse a question. A refused
predicateorscorequestion scores that document (or that composite criterion) 0.0; a refused listwisechoicefails the attempt ascontent_filter, so a virtual model withfallback_on = ["content_filter"]moves to its next target. Every refusal is counted inlumen_decision_refusals_total{model}, including those of achoicethat then falls back. - Because scores are calibrated probabilities, they are comparable across requests: a cut-off such as 0.5 means the same thing everywhere.
- Usage: the upstream
input_tokensisusage.total_tokens(unflagged when the upstream reports it); search units are derived. The decision model’scost_per_1m_inputbills per input token. - The remap belongs to a target of a virtual model. For per-tenant relevance
rules, put a
switchon the budget group in front of several such virtual models.
Providers
Which provider kinds serve rerank and their setup is in
Providers.
Decisions (typed answers)
POST /v1/decisions answers typed questions about some content with
calibrated probabilities, instead of generating text. A decision model gets an
input (text, or text and images) and a list of questions, and returns one
answer per question: a probability for a yes/no question, a distribution over
options for a choice, a distribution over ordered levels for a score.
Decision models come from several vendors: TypeSafe (Jev), Perplexity
(pplx-decider-*), OpenAI (gpt-6-luna), Cloudflare (Clef), local models
through Ollama (Nimble, Clef, Kev) and any vendor that speaks the TypeSafe
format (Liquid, Inception, Upstage). LUMEN serves all of them behind one
capability, decisions, so any of them can be a target of the same
virtual model (fallback, split, switch) and a
reranker.
The design and its trade-offs are in
ADR 017.
The model field is one of your configured model ids: the id of a
[[providers.models]] block declaring capabilities = ["decisions"], or a
virtual model of capability decisions. Provider setup is in
Providers.
Configuration
[[providers]]
name = "typesafe"
kind = "typesafe"
api_key_env = "TYPESAFE_API_KEY"
[[providers.models]]
id = "jev"
upstream_id = "jev-latest"
capabilities = ["decisions"]
cost_per_1m_input = 0.042
The old capability spelling systemone still loads (with a boot warning);
lumen config migrate rewrites it.
Two request formats
/v1/decisions accepts both formats, detects which one from the body, and
answers in the format it received. Clients of every vendor point at LUMEN with
no code change: the OpenAI SDK, Perplexity-style and TypeSafe-format HTTP
clients (TypeSafe clients that post to /v1/systemone switch the path to
/v1/decisions, see Migrating).
| The body has | Format |
|---|---|
input, and questions is an array | OpenAI |
state, and questions is an object | TypeSafe (also Perplexity’s) |
| anything else (both, neither, mismatched shape) | LM-1001: “send either OpenAI format (input, questions array) or TypeSafe format (state, questions object)” |
Either format can be served by any provider kind: LUMEN translates between
them. When the format of the request matches the family of the upstream (a
TypeSafe-format request to Jev or Perplexity), the question bodies and state
are sent verbatim, key order included, and the upstream answers come back
verbatim, including unknown fields.
TypeSafe format
curl -s http://localhost:8080/v1/decisions \
-H 'content-type: application/json' \
-d '{
"model": "jev",
"state": "Help! My payouts have been failing for 3 days.",
"questions": {
"is_urgent": {
"type": "noul",
"instructions": "Does this message convey urgency?"
},
"department": {
"type": "choice",
"instructions": "Which team should handle this ticket?",
"criteria": { "technical": "Bugs and outages", "billing": "Payments and invoices" }
}
}
}'
state is the content to evaluate: a string, an object or an array. Each key
of questions is your own question id, and the answer comes back under the
same id.
{
"model": "jev-1.13.0",
"answers": {
"is_urgent": { "type": "noul", "noul": 0.95 },
"department": {
"type": "choice",
"choice": "billing",
"probabilities": { "technical": 0.12, "billing": 0.88 },
"confidence": 0.81
}
},
"usage": { "input_tokens": 318, "output_tokens": 34 }
}
(The numbers are illustrative.) model is the versioned upstream model that
answered (not your configured id). The id that actually served the request,
primary or fallback, is in the x-lumen-model-used response header, as for
every capability.
type | Asks | instructions | criteria |
|---|---|---|---|
noul | A yes/no question; answers the probability of yes. | required, unless criteria is given | optional object (what true / false mean) |
choice | Pick one option; answers the choice, its distribution and a confidence. | required | required: an object of 1 to 255 options (option id to description, or null) |
score | Rate against ordered levels; answers the level with its legend, distribution and a confidence. | required | required: an array of 1 to 10 levels |
OpenAI format
curl -s http://localhost:8080/v1/decisions \
-H 'content-type: application/json' \
-d '{
"model": "gpt-6-luna",
"input": "Help! My payouts have been failing for 3 days.",
"questions": [
{
"type": "predicate",
"name": "is_urgent",
"instructions": "Does this message convey urgency?"
},
{
"type": "choice",
"name": "department",
"instructions": "Which team should handle this ticket?",
"choices": [
{ "value": "technical", "description": "Bugs and outages" },
{ "value": "billing", "description": "Payments and invoices" }
]
}
]
}'
Answers come back as an array in question order:
{
"model": "gpt-6-luna",
"answers": [
{ "type": "predicate", "name": "is_urgent", "probability": 0.95 },
{
"type": "choice",
"name": "department",
"choice": "billing",
"probabilities": [
{ "value": "technical", "probability": 0.12 },
{ "value": "billing", "probability": 0.88 }
],
"confidence": 0.88
}
],
"usage": {
"input_tokens": 318,
"input_tokens_details": { "cached_tokens": 0, "cache_write_tokens": 0 },
"output_tokens": 34,
"output_tokens_details": { "reasoning_tokens": 0 },
"total_tokens": 352
}
}
When the upstream is not OpenAI, usage is completed to OpenAI’s schema so
typed SDKs deserialize it, and "estimated": true is added only when LUMEN
estimated the count. The OpenAI SDKs type confidence as required on choice
and score answers: when the upstream gave none, LUMEN uses the highest
probability.
With the OpenAI SDK, point base_url at LUMEN (use an SDK release that
includes client.decisions):
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8080/v1", api_key="sk-lumen-...") # any value when auth is off
response = client.decisions.create(
model="gpt-6-luna",
input="Help! My payouts have been failing for 3 days.",
questions=[
{"type": "predicate", "name": "is_urgent", "instructions": "Does this message convey urgency?"},
],
)
print(response.answers[0].probability)
Question type | Fields |
|---|---|
predicate | instructions (required) |
choice | instructions, choices: 2 to 255 unique {value, description?}; value is a string or a boolean |
score | instructions, levels: at least 1 {label, description?} |
name is optional but must be unique when present. safety_identifier
(at most 128 characters) is forwarded to OpenAI and dropped for other vendors.
Validation and errors
LUMEN checks the documented request contract itself, before any upstream
call, so a malformed question is a precise 400 instead of an opaque
upstream rejection.
LM-2011(400): emptyquestions(an empty array or an empty map).LM-1001(400): every other contract violation, with a message naming the offending question. A body that is not JSON, or that matches neither format; a missingmodel; in the OpenAI format, an unknown top-level parameter (as OpenAI itself answers), more than 128 images, achoicewith fewer than 2 or more than 255 options, a duplicate name; in the TypeSafe format, anullstate, a missing or unknown questiontype, missinginstructions(unless anoulhascriteria), a malformedcriteria. A request that no target in the chain can take is alsoLM-1001, found before any call (see Cross-vendor fallback).LM-2003(400): an image sent to a model whosemodalitieslack"image". For a virtual model, the message names the leaf model that could not take the image, not the virtual id.LM-2004(400): an image that is not adata:URL. Remotehttp(s)image URLs are refused in both formats.
There is no question-count cap at the gateway edge; per-target caps apply
when a target is chosen (Perplexity 128 questions, Cloudflare 64 questions and
4 images, Ollama choice and score 2 to 26 options, OpenAI choice at
least 2 options). All codes are in Error codes.
For example, an empty choice:
curl -s http://localhost:8080/v1/decisions \
-H 'content-type: application/json' \
-d '{
"model": "jev",
"state": "Help! My payouts have been failing for 3 days.",
"questions": { "department": { "type": "choice", "instructions": "Which team?", "criteria": {} } }
}'
{ "error": { "code": "LM-1001", "message": "question `department`: choice needs 1 to 255 options, got 0", "type": "invalid_request" } }
Upstream failures follow the usual mapping (see Error codes):
| Upstream status | LUMEN | Retried / falls back |
|---|---|---|
401, 422 | LM-3003 (502), names the provider; the upstream body is not forwarded | no |
429 | LM-3001 (429), honouring Retry-After | yes |
529 Overloaded, 5xx | a retryable 5xx (LM-3003) | yes: retries, then the virtual model’s next target |
An upstream 401 means the gateway’s upstream key is wrong: it surfaces as a
502 naming the provider, never as a misleading 401 to your client.
Images
Declare modalities = ["text", "image"] on the models that take images (the
default is ["text"], so nothing changes silently):
[[providers.models]]
id = "gpt-6-luna"
capabilities = ["decisions"]
modalities = ["text", "image"]
cost_per_1m_input = 0.10
A client sends images in one of two ways at the edge, and LUMEN places them where each upstream expects them:
| Client format | Where the image goes |
|---|---|
| OpenAI | input as user messages with input_image parts (image_url is a data: URL; at most 128 per request) |
| TypeSafe | inside a state array, as {"type": "image_url", "image_url": {"url": "data:..."}} parts (Perplexity’s convention) |
Upstream, the image lands in one of three places, by provider kind: inside
state as image_url parts (perplexity), in a top-level images array
(ollama with raw base64, cloudflare), or as OpenAI’s input_image
(openai). A model without "image" in its modalities never receives an
image: it is skipped in a fallback chain, or the request fails with LM-2003
when nothing is left.
Images make bodies large: one 1024x1024 PNG is about 1 to 3 MiB as base64, and
a request with many images can far exceed the default 10 MiB
server.body_limit. Raise it to 32 MiB (Perplexity’s own limit) for
deployments that send images:
[server]
body_limit = 33554432 # 32 MiB
The symptom of a too-small limit is a 413 with LM-1002.
Cross-vendor fallback
Any decision model can be a target of the same virtual model. For example,
Perplexity first (cheapest), then Jev, then gpt-6-luna:
[[providers]]
name = "perplexity"
kind = "perplexity"
api_key_env = "PERPLEXITY_API_KEY"
[[providers.models]]
id = "pplx-decider"
upstream_id = "pplx-decider-v1.1-27b"
capabilities = ["decisions"]
modalities = ["text", "image"]
cost_per_1m_input = 0.02
[[providers]]
name = "typesafe"
kind = "typesafe"
api_key_env = "TYPESAFE_API_KEY"
[[providers.models]]
id = "jev"
upstream_id = "jev-latest"
capabilities = ["decisions"]
cost_per_1m_input = 0.042
[[providers]]
name = "openai"
kind = "openai"
api_key_env = "OPENAI_API_KEY"
[[providers.models]]
id = "gpt-6-luna"
capabilities = ["decisions"]
modalities = ["text", "image"]
cost_per_1m_input = 0.10
[[virtual_models]]
id = "cross"
capability = "decisions"
strategy = "fallback"
targets = [{ model = "pplx-decider" }, { model = "jev" }, { model = "gpt-6-luna" }]
Clients send "model": "cross" in either format. Before any upstream call,
each target of the chain is checked against the request, and an incompatible
one is skipped: it is not an attempt for metrics or usage_log, never
counts as a circuit-breaker failure, and is logged at debug without content.
If no target is left, the first incompatibility is returned.
| Rule | Who | Error when nothing is left |
|---|---|---|
An image needs "image" in the model’s modalities | all | LM-2003 |
| More images than the target allows | Cloudflare (4) | LM-1001 |
A choice with 1 option | OpenAI, Ollama | LM-1001 |
A choice or score with more than 26 options or levels | Ollama | LM-1001 |
| More questions than the target allows | Perplexity (128), Cloudflare (64) | LM-1001 |
A noul without instructions | typesafe models | LM-1001 |
A choice with a string and a boolean of the same spelling ("true" and true) | TypeSafe-family targets | LM-1001 |
Refusals. OpenAI is the only vendor that can answer {"type": "refusal"}
for a question. An OpenAI-format client receives it as is. A TypeSafe-format
client receives {"type": "refusal"} under that question id, a shape the
TypeSafe and Perplexity SDKs do not know and may fail to deserialize. That only
happens when an operator puts an openai-kind target behind a TypeSafe-format
client, as in the cross example. Every refusal is counted in
lumen_decision_refusals_total{model}. In a rerank remap, a refused
predicate or score scores 0.0 and a refused listwise choice fails the
attempt as content_filter, which a virtual model with
fallback_on = ["content_filter"] fails over on.
Token accounting and cost
Every /v1/decisions response carries a token count (ADR 003):
- When the upstream reports
usagewith a non-zeroinput_tokens, it is used as is, unflagged. A missing or malformedusagenever fails a billed answer. - Otherwise the gateway estimates input tokens with its byte heuristic
(bytes / 4) over all text of the input and every question as sent, plus a
flat per-image estimate, and flags the response
"estimated": true. Output is estimated as 0 (answers are tiny and not billed); an upstreamoutput_tokenscount is kept when present. Admission reserves the same estimate before the upstream call.
Decision models bill input tokens only, so price them with
cost_per_1m_input and no output price:
| Model | cost_per_1m_input |
|---|---|
| Jev (TypeSafe) | 0.042 |
pplx-decider-* (Perplexity) | 0.02 |
gpt-6-luna (OpenAI) | 0.10 |
| Clef (Cloudflare) | 0.24 |
| Ollama (local) | 0 |
OpenAI’s cached_tokens and reasoning_tokens are passed through, not
billed. Prices are the published ones as of 2026-10; set them on your models,
the gateway does not fill them in.
The counts feed lumen_tokens_total{capability="decisions",direction="input"|"output",...}
and, when auth is on, usage_log rows with capability = 'decisions'. Old
rows written under the previous name stay systemone:
GET /admin/usage?capability= accepts decisions or systemone and matches
both, and group_by=capability shows old rows as systemone and new rows as
decisions. Virtual keys, RPM/TPM quotas and hard budgets apply exactly as for
the other capabilities. See
Token accounting & cost and
Keys, quotas & budgets.
Privacy. input, images, state, questions, answers and
safety_identifier are request content: they are never logged, never written
to usage_log, and never included in errors.
Local decision models with Ollama
Ollama 0.35 and later serves decision models on POST /v1/systemone, with no
auth. Pull a model and add an ollama provider:
ollama pull nimble
[[providers]]
name = "ollama-local"
kind = "ollama"
base_url = "http://localhost:11434" # the server root, no /v1
[[providers.models]]
id = "nimble"
upstream_id = "nimble"
capabilities = ["decisions"]
Limits to know: a choice or score takes 2 to 26 options or levels; images
are accepted only by clef and clef-flash (declare their modalities
accordingly); unknown request fields are stripped. For a rerank remap, an
Ollama target gets about 16k tokens per call and one call at a time (a
local GPU), and a listwise choice over more than 26 documents is LM-1001.
A server that is not running is LM-3004 and falls back like any unavailable
upstream. A model can take a while to load into VRAM on its first call: relax
first_token_timeout_ms and total_timeout_ms on the provider block (see
Providers).
Migrating from /v1/systemone
POST /v1/systemone, the TypeSafe-format-only route of 0.6.x, is removed
in 0.7.0: it now answers 404 LM-1003 like any unknown route.
- Send the same body to
/v1/decisions. Nothing else changes in the request or the response:/v1/decisionsdetects the TypeSafe format and answers in it. - Rewrite your config. Run
lumen config migrate(add--dry-runto preview): it renames the capabilitysystemonetodecisionsand the remap strategynoultopredicate. The old spellings still load, with a boot warning. - Update dashboards. The
capabilitylabel of every metric and the capability inGET /v1/modelschange fromsystemonetodecisions. This is a breaking change for queries that filter on the old value.usage_logkeeps old rows as they were (see above).
The TypeSafe SDKs call /v1/systemone, so they no longer go through LUMEN
from 0.7.0: call /v1/decisions with the same body over plain HTTP instead.
Upstream providers are unaffected: the gateway still calls TypeSafe, Ollama
and the other TypeSafe-family vendors on their own /v1/systemone.
Limitations
- No streaming.
/v1/decisionsis request/response only. - Jev rate limits (TypeSafe, 2026-09): 64k tokens of context per request
(32k for
stateplus the longest question), 250k tokens/s and 1200 RPM, dynamic. Perplexity: under 262,144 input tokens, 32 MiB body, 10 RPS per org. LUMEN does not check context limits itself: a request an upstream rejects surfaces asLM-3003. To stay under upstream rate limits across tenants, set per-key RPM/TPM quotas. - Reranking goes through
/v1/rerank, not this endpoint: declare a rerank virtual model whose target carries aremaponto a decision model; see Decision models as rerankers. Per-tenant relevance rules use aswitchon the budget group (design notes).
Providers
Which provider kinds serve decisions, with URLs, auth and limits, is in
Providers.
Virtual models
A virtual model is a public id, such as acme/legal-rerank, that carries
routing logic: ordered fallback, weighted split, conditional routing, request
templates, and Jev answering /v1/rerank. It sits on top of the foundation
models you declare under [[providers.models]], which stay plain upstream
bindings. The design is recorded in ADR 014.
The examples below assume the foundation models of
config.example.toml
(openai/gpt-4o, claude-sonnet-4-5, jev, cohere/rerank-english, and so
on). Every snippet is valid TOML; note that a TOML inline table { ... } must
stay on one line, so long targets use [[virtual_models.targets]] sub-tables.
1. Foundation models vs virtual models
| Foundation model | Virtual model | |
|---|---|---|
| Declared in | [[providers.models]] | [[virtual_models]] |
| What it is | An upstream binding: id, upstream_id, capabilities, modalities, prices, release_date | A name with logic over one or more targets |
| Callable by clients | Yes | Yes |
| Can carry routing logic | No | Yes |
Both layers share one id namespace. Ids are non-empty and use only the
characters A-Z a-z 0-9 . _ : / -; company/model is a convention, not a
rule. A collision between a foundation id and a virtual id is a validation
error naming both. A virtual model serves exactly one capability (chat,
embed, rerank or decisions; systemone is accepted as an alias of
decisions); to offer the same product name for two
capabilities, define two ids.
Virtual models live in the dynamic config document (config modes): they hot-reload and are editable through the admin API (section 9). An in-flight request keeps the plan it started with.
GET /v1/models lists virtual models next to foundation models, with
"virtual": true, a single-entry capabilities array, the optional
description, and the modalities common to every foundation model the virtual
model can reach. Foundation entries carry "virtual": false. Set
listed = false to hide a virtual model from the list; it stays callable.
[[virtual_models]]
id = "gpt-4o"
capability = "chat"
strategy = "fallback"
description = "GPT-4o with a Claude backup"
listed = true
targets = [{ model = "openai/gpt-4o" }, { model = "claude-sonnet-4-5" }]
Calling an id that is neither kind is LM-2001; calling a virtual model on
the wrong endpoint (say a rerank model on /v1/chat/completions) is
LM-2002.
2. Strategies
Every virtual model has one strategy:
| Strategy | Behaviour | Targets |
|---|---|---|
single | The one target. | Exactly 1 |
fallback | Targets in order; move to the next when the failure matches fallback_on. | 2 or more |
split | Pick one target at random by integer weight (> 0, required on every target). If it fails with a matching trigger, try the others in descending weight order (ties keep declaration order). No stickiness. | 2 or more |
switch | The first target whose when matches. A chosen branch never falls through to the next rule; its resilience comes from what it points at (for example a fallback virtual model). | Every target but the last has a when; the last has none and is the mandatory default |
Field placement is enforced: weight only under split, when only under
switch, fallback_on only on fallback and split, preset only on
chat.
# 80% of traffic to OpenAI, 20% to Azure OpenAI; a failing pick moves to the other.
[[virtual_models]]
id = "acme/chat-ab"
capability = "chat"
strategy = "split"
targets = [{ model = "openai/gpt-4o", weight = 80 }, { model = "azure-gpt-4o", weight = 20 }]
Composition and limits
A target’s model may be another virtual model, so a fallback can point at a
split, a switch branch at a fallback, and so on. At load (boot, reload, and
every admin write) the gateway checks the following and reports the first
failure with the virtual model id and the reason:
- every
modelreference exists; - strategy shape (the table above);
- every target serves the virtual model’s capability, or carries a
remapthat makes it compatible (section 7); - no cycles (the error prints the path, for example
a -> b -> a); - nesting depth at most 8 virtual models from the requested id down to a foundation model;
- at most 64 attempts per request: a
fallbackorsplitcounts the attempts of every target, aswitchonly its largest branch, and a foundation target counts 1 (a DAG that lists the same child several times per level multiplies quickly; the error names the model and its count); - the contents of
overrides,presetandremap.
A preset on a virtual model that is only reached through another one is
ignored at request time (section 6), and loading logs a warning.
3. fallback_on triggers
fallback_on lists the failures that let a fallback or split move on to
its next target.
| Trigger | Matches |
|---|---|
provider_error | Retryable upstream 5xx (LM-3003, including a TypeSafe 529 Overloaded), an unreachable upstream, or a stream that ends before its first content frame (LM-3010). A malformed response (LM-3002) and non-retryable upstream statuses never trigger a fallback. |
rate_limited | Upstream 429 (LM-3001). |
timeout | Connect, first-token and per-attempt timeouts (LM-3005, LM-3011, LM-3012). |
circuit_open | The target’s circuit breaker is open. |
context_length | Opt-in. The upstream rejected the input as too long (surfaces as LM-2012 when nothing absorbs it). |
content_filter | Opt-in. The upstream refused on content policy (surfaces as LM-2013). |
- Default when
fallback_onis omitted:["provider_error", "rate_limited", "timeout", "circuit_open"], exactly the behaviour of the earlier per-modelfallbacks. context_lengthandcontent_filterrely on the provider classifying its upstream error body. A provider that cannot classify an error leaves it as a plain client error, and nothing fails over. Classification is implemented for OpenAI and the OpenAI-compatible hosts, Anthropic, Google/Vertex, Bedrock, Mistral and Cohere.- Never triggers: a malformed upstream response (
LM-3002), a non-retryable upstream status (for example a TypeSafe401or422, which surface asLM-3003but are not retried), any other client 4xx, a client cancellation (which never touches a circuit breaker, ADR 006), and any failure after the first streamed byte. Once a streaming response has delivered content the request is committed; a later error becomes an SSE error frame, never a fail-over (Streaming). - Retries still happen inside each target first
(Resilience tuning), and
[resilience].total_timeout_msbounds the whole tree (LM-3013).
# Also fail over when the prompt is too long for the first model.
[[virtual_models]]
id = "acme/long-context"
capability = "chat"
strategy = "fallback"
fallback_on = ["provider_error", "timeout", "context_length"]
targets = [{ model = "openai/gpt-4o" }, { model = "gemini-long" }]
4. switch conditions
A switch target carries a when table. Attributes:
| Attribute | Type | Source |
|---|---|---|
group | string | The authenticated key’s budget group (keys and budgets). Missing when auth is off or the key has no group. |
"metadata.<key>" | string, number or bool | A top-level field of the x-lumen-metadata header (alias cf-aig-metadata, ADR 002). |
has_images | bool | A chat or embed request contains an image part. |
has_tools | bool | The chat request declares tools. |
stream | bool | The chat request is streaming. |
input_tokens | integer | The token accounting input estimate. Computed only when some rule in the plan references it. For a chat preset, the preset’s system prompt is applied before routing, so it counts. |
documents | integer | The rerank document count. |
The virtual key id is deliberately not an attribute. Using an attribute the
capability never has (for example has_tools on a rerank model) is a
validation error.
Quote metadata.<key> in TOML. An unquoted metadata.plan = "pro" is a
nested table metadata = { plan = "pro" } and is rejected; write
"metadata.plan" = "pro".
Operators. A bare value means equality. Otherwise use a one-key table:
in (a list), ne, gt, gte, lt, lte, or regex (compiled at load;
an invalid regex is a validation error). Several keys in one when table are
ANDed. any = [ {...}, {...} ] is an OR of tables and can nest. A missing
attribute makes its comparison false (including ne), so the branch does not
match and evaluation moves on to the next rule.
[[virtual_models]]
id = "acme/chat"
capability = "chat"
strategy = "switch"
targets = [
{ when = { group = "eu-customers" }, model = "acme/chat-eu" },
{ when = { "metadata.plan" = { in = ["pro"] }, input_tokens = { gt = 32000 } }, model = "gemini-long" },
{ when = { any = [{ has_images = true }, { has_tools = true }] }, model = "gpt-4o" },
{ model = "local-llama" },
]
The first rule sends a whole budget group to a regional model. The second
needs both the metadata plan and a long input. The third sends image or tool
requests to a capable model. The last target has no when and is the default.
The regex operator works the same way: { group = { regex = "^tenant-[0-9]+$" } }.
5. Overrides
overrides on a target rewrites request fields for that target:
targets = [{ model = "openai/gpt-4o", overrides = { set = { max_tokens = 4096 }, default = { temperature = 0.3 }, drop = ["seed"] } }]
setalways applies;defaultapplies only when the client did not send the field;dropremoves it.- On translated providers (Anthropic, Bedrock, Google, Cohere) a client-sent
max_tokenswins overmax_completion_tokens. To cap output tokens there with an override,setmax_tokens. - Overrides act on the parsed request, not on JSON paths, and each capability has an allow-list of fields:
| Capability | Allowed fields |
|---|---|
chat | temperature, top_p, max_tokens, max_completion_tokens, stop, seed, presence_penalty, frequency_penalty, response_format, reasoning_effort, parallel_tool_calls |
embed | dimensions, encoding_format |
rerank | top_n |
decisions | none |
An unknown field, a field outside the allow-list, or a value of the wrong type is a validation error.
Order of application for one request: the requested virtual model’s
preset (section 6) first, then target overrides from the outermost virtual
model inward, so the level closest to the foundation model wins.
6. Presets (chat)
A preset gives a chat virtual model a stored system prompt and its own
sampling defaults:
[[virtual_models]]
id = "acme/support-bot"
capability = "chat"
strategy = "fallback"
targets = [{ model = "gpt-4o" }, { model = "claude-sonnet-4-5" }]
description = "Acme support assistant"
preset = { system_prompt = "You are Acme's support assistant. Answer briefly and point to the documentation when relevant.", system_prompt_mode = "prepend", overrides = { default = { temperature = 0.2 }, set = { max_tokens = 800 } } }
system_prompt_mode | Effect |
|---|---|
prepend (default) | The preset becomes the first message, before any client system message. |
replace | Client system messages (role system or developer) are removed; the preset takes their place. |
if_absent | Applied only when the client sent no system message (role system or developer). |
system_promptis at most 32 KiB and must not be blank.overridesuses the syntax and allow-list of section 5.- Token accounting. The injected prompt is part of the request: it counts
in
usage.prompt_tokens, in the usage log and in cost, exactly as if the client had sent it. It is applied before routing, soinput_tokensswitch conditions count it too. - Only on the requested id. A preset applies only when its own virtual model is the id the client sent. A preset on a virtual model reached through composition is ignored, and validation warns about it at load.
- Preset text is operator-authored config: never client-supplied, never logged, never echoed in errors.
7. Decision models as rerankers
A remap on a target of a rerank virtual model converts POST /v1/rerank into decision calls. The target must
point directly at a foundation model that serves decisions: any of the
typesafe (Jev and other TypeSafe-format vendors), perplexity, openai,
ollama and cloudflare kinds. A typesafe model can no longer declare
rerank itself.
The state sent upstream is { "query": <query> }, plus "context" when set.
Each question’s instructions are structured as { "document": <document>, "question": <instructions> }. Jev receives exactly these bytes; an OpenAI
target receives them as compact JSON text.
strategy | Fields | Questions per request | relevance_score |
|---|---|---|---|
predicate (default; alias noul) | instructions, criteria.true, criteria.false (all default to a generic relevance question) | One predicate per document | The predicate probability |
score | instructions, levels (worst first, 2 to 10 entries) | One score per document | level / (levels - 1) |
composite | questions: 1 to 8 entries of { instructions, criteria.true, criteria.false, weight > 0 } | N predicates per document | sum(weight_i * p_i) / sum(weight_i) |
choice | instructions | One choice question; the options are the documents | The option’s probability (scores sum to 1) |
context is a static, operator-authored string sent in the state next to the
query, for rules that hold for every request of this virtual model (“US
federal case law. Prefer holdings over dicta.”).
Batching and limits
predicate,scoreandcompositeare batched: by default at most 100 documents and about 48k estimated tokens per upstream call, with 4 calls in flight. Each target’s limits tighten that: Cloudflare packs at most 64 documents per call, Ollama about 16k tokens per call with one call in flight. Forcompositethe per-call budget counts every question of every document.choicemust fit one call, because its scores are relative and cannot be merged across calls: at most 255 documents (26 on Ollama) and within the per-call token budget. Otherwise the request fails withLM-1001(400) naming the limit, before any upstream call. Achoiceover one document returns relevance 1.0 without an upstream call.- Documents are truncated to about 4,096 estimated tokens.
Refusals. An OpenAI target can refuse a question. A refused predicate or
score scores that document (or that composite criterion) 0.0. A refused
choice fails the attempt as content_filter: with
fallback_on = ["content_filter"] on the virtual model, the request moves to
the next target, otherwise it surfaces as LM-2013. Every refusal increments
lumen_decision_refusals_total{model}, including a refused choice that falls
back.
Accounting. usage.total_tokens is the sum of the input_tokens the
decision model reports over all calls (upstream-reported, not estimated).
Cost is the decision model’s cost_per_1m_input; search_units stay derived
from the document count. x-lumen-model-used names the decision model.
Jev with a Cohere fallback. A remap target and a plain reranker in one
fallback give a cross-capability fallback. A TypeSafe 529 Overloaded is a
provider_error, so the default triggers already send the request to Cohere.
This composite example is also in config.example.toml:
[[virtual_models]]
id = "acme/legal-rerank"
capability = "rerank"
strategy = "fallback"
[[virtual_models.targets]]
model = "jev"
[virtual_models.targets.remap]
strategy = "composite"
context = "US federal case law. Prefer holdings over dicta."
[[virtual_models.targets.remap.questions]]
instructions = "Is `document` on the topic of the query?"
weight = 0.3
criteria.true = "It discusses the same legal question."
criteria.false = "It is about something else."
[[virtual_models.targets.remap.questions]]
instructions = "Does `document` state the rule the query cites?"
weight = 0.7
criteria.true = "It states the specific rule."
criteria.false = "It only mentions related topics."
[[virtual_models.targets]]
model = "cohere/rerank-english"
Per-tenant relevance rules combine remap with switch on the budget group:
[[virtual_models]]
id = "acme/rerank"
capability = "rerank"
strategy = "switch"
targets = [{ when = { group = "law-firm" }, model = "acme/legal-rerank" }, { model = "jev-rerank" }]
The other strategies in one line each:
[[virtual_models]]
id = "acme/graded-rerank"
capability = "rerank"
strategy = "single"
[[virtual_models.targets]]
model = "jev"
remap = { strategy = "score", instructions = "How well does `document` answer the query?", levels = ["off topic", "related", "partial answer", "full answer"] }
[[virtual_models]]
id = "acme/pick-rerank"
capability = "rerank"
strategy = "single"
[[virtual_models.targets]]
model = "jev"
remap = { strategy = "choice", instructions = "Which document best answers the query?" }
score with 4 levels gives 0, 1/3, 2/3 and 1. Remap text is
operator-authored config: never client-supplied, never logged, never echoed in
errors.
8. Observability
| Where | What |
|---|---|
x-lumen-model-used response header | The foundation model that served (unchanged). |
x-lumen-route response header | The path taken, for example acme/chat>acme/chat-eu>mistral-large. Set when a virtual model was requested. |
usage_log.model / model_used | The requested id / the foundation model that served. |
usage_log.route | The same path as the header (nullable; empty for a direct foundation call). See the usage log. |
lumen_virtual_model_requests_total{virtual_model, model_used} | Counter of requests served through a virtual model. Virtual ids are operator-defined, so cardinality is bounded. See metrics. |
| Tracing | virtual_model and route span fields. Never prompts, presets or remap text. |
Tokens and cost are booked to the foundation model that served, so a fallback that fires costs what the fallback model costs (token accounting).
9. Admin API
All routes are under the config admin surface (config
modes) and work in both config_source modes.
| Route | Purpose |
|---|---|
GET /admin/config/virtual_models | List (id, capability, strategy) plus the document hash. |
GET /admin/config/virtual_models/{id} | One virtual model plus hash. An unknown id is 404 LM-1003. |
PUT /admin/config/virtual_models/{id} | Create or replace. Requires If-Match with the current hash, like every mutating config route. A path id that differs from the body id is LM-1001. |
DELETE /admin/config/virtual_models/{id} | Remove. Requires If-Match. |
GET /admin/config/virtual_models/{id}/plan | The fully resolved tree: every reference expanded down to foundation models, with strategies, weights, conditions and triggers. |
{id} is percent-encoded when it contains /:
curl -s -H "Authorization: Bearer $LUMEN_MASTER_KEY" \
http://localhost:8080/admin/config/virtual_models/acme%2Flegal-rerank/plan
The /plan body shows preset and remap text, since it is an admin route
behind the master key; it is never logged.
Dependency safety. Deleting a provider, a foundation model or a virtual
model that another virtual model still references is LM-1001, naming the
dependent. Every write is validated with the same rules as boot, so an invalid
virtual model (a cycle, a missing reference, a bad when) is LM-1001 naming
the model and the reason.
10. Migrating from fallbacks
Per-model fallbacks, the [providers.models.rerank] block, and the rerank
capability on typesafe models are removed. A config that still uses one
fails validation naming the model, and prints the converted snippet.
lumen config migrate -c config.toml --dry-run # print the result, change nothing
lumen config migrate -c config.toml # rewrite (file mode keeps config.toml.bak)
lumen config migrate [-c PATH] [--dry-run] rewrites the document. In file
mode it backs the file up to <file>.bak and replaces it atomically; in DB
mode it writes a new config_versions row. Formatting and comments outside
the edited tables are kept, and the result is validated before anything is
written.
The rename rule. To keep clients working unchanged, the virtual model takes over the public id and the foundation model is renamed:
- A foundation model
Mon providerPwithfallbacks = [F1, F2]becomes the foundation modelP/M, and a virtualMis created withstrategy = "fallback"andtargets = [P/M, F1, F2]. The renamed model keeps sending its old upstream id: the migrator pinsupstream_idto the old value when the model had none. - Only models with
fallbacksare renamed. IfP/Malready exists, the migrator stops with an error naming it. - If
Mdeclares several capabilities, one id can carry only one virtual model: the virtualMgets the first declared capability, and the others are printed as needing a decision. Until you add a second virtual model, a call toMfor another capability returnsLM-2002; it still works throughP/M, without fallback.--dry-runshows this before anything is written. - A
typesafemodelRdeclaringrerank(with or without a converter block) becomes a virtualRwithcapability = "rerank",strategy = "single", and one target with apredicateremap that copies the converter’s fields, pointing at the provider’s decisions foundation model with the sameupstream_id. If none exists, the migrator createsP/<upstream_id>withcapabilities = ["decisions"]. When the prices of the removed reranker differ from that model’s, a note asks you to review them. - The capability spelling
systemone(modelcapabilities, virtual modelcapability) becomesdecisions, andremap.strategy = "noul"becomespredicate(ADR 017). Both old spellings keep loading without the migrator and log a boot warning.
Visible side effect. usage_log.model_used and the Prometheus model
labels of renamed foundation models change, for example gpt-4o becomes
openai/gpt-4o. The id clients send does not change. Update dashboards and
queries that filter on the old value.
Before and after:
# before (no longer valid)
[[providers.models]]
id = "gpt-4o"
capabilities = ["chat"]
fallbacks = ["claude-sonnet-4-5"]
# after
[[providers.models]]
id = "openai/gpt-4o"
upstream_id = "gpt-4o"
capabilities = ["chat"]
[[virtual_models]]
id = "gpt-4o"
capability = "chat"
strategy = "fallback"
targets = [{ model = "openai/gpt-4o" }, { model = "claude-sonnet-4-5" }]
See also ADR 014 and the Error codes.
Token accounting & cost
EVERY request of every capability (chat, embed, rerank) produces a token
count. Never a silent zero: upstream usage is used when a provider reports
it, otherwise the gateway falls back to a local estimate flagged
"estimated": true. This is a central reason LUMEN exists, not an
afterthought - see ADR 003.
Why it has to be guaranteed
Upstream usage reporting is inconsistent across providers: TEI’s /embed
returns a bare vector array with no usage at all; streaming chat only
carries usage when the client asks for it (and some providers omit it even
then); rerank is billed in search units by Cohere and Pinecone but in
tokens by Jina/Voyage, and TEI reports neither. A gateway that only passes
through whatever the upstream says would show 0 tokens for a large slice
of traffic. LUMEN closes that gap: a count is guaranteed for every call, and
the count is always honest about whether it was measured or estimated.
Where it surfaces
- Response body - the OpenAI-compatible
usageobject on chat and embeddings responses, andusage.search_unitsplususage.total_tokenson rerank responses (rerank carries both a search-unit and a token count, each independently flagged -estimatedforsearch_units,tokens_estimatedfortotal_tokens- see Reranking - Billing). - Prometheus -
lumen_tokens_total(every capability, including rerank’s token count) andlumen_rerank_search_units_total(rerank’s search-unit count), both carrying anestimated(or upstream-reported) signal. See Metrics & dashboards. usage_log(when[auth]is enabled) - per-request token counts, cost and theestimatedflag, alongside status and metadata for later slicing. See Usage log & multi-tenant metadata.
Cached and reasoning token breakdown
When an upstream reports a token breakdown, the gateway surfaces it instead of discarding it (issue #99), on the same three sinks and under the same “never zero, never invented” rule:
- Response body - an OpenAI-compatible
usage.prompt_tokens_details(cached_tokens) andusage.completion_tokens_details(reasoning_tokens). For Anthropic prompt caching,cache_read_input_tokensmaps tocached_tokens(a cache read/hit, same as OpenAI) andcache_creation_input_tokensrides a distinctcache_creation_tokensfield inprompt_tokens_details(a cache write, which has no OpenAI equivalent). - Prometheus -
lumen_token_breakdown_total{capability, model, provider, kind}withkindone ofcached,reasoning,cache_write. It is a subset oflumen_tokens_total, split out so cache and reasoning usage can be summed on their own. usage_log- the nullablecached_tokens,reasoning_tokensandcache_write_tokenscolumns.
A breakdown is only ever an upstream fact: when the upstream reports none, all
three surfaces omit it (None/NULL/no series), and a locally estimated count
carries no breakdown at all.
How estimation works
Source priority, in order:
- Upstream-reported usage. Authoritative and free - already present in
the response body or the final SSE chunk.
estimated = false. - Local estimation fallback, when the upstream omits usage.
estimated = true. The default is a cheap, allocation-light byte/char heuristic that is safe to run anywhere, including the request path; an accurate tokenizer is an opt-in, off-hot-path option run viaspawn_blocking.
The hot-path rule holds regardless: the request path never runs a heavy
tokenizer. Upstream usage is passed through as-is; when it is missing, the
cheap heuristic fills in the response usage field, flagged estimated,
and never blocks or slows the request. Streaming chat estimates the same
way when the upstream sends no usage at all in its final chunk.
Images. Upstream-reported usage already folds in image cost (OpenAI,
Anthropic and Gemini all report image tokens as part of prompt_tokens),
so a vision request with upstream usage is exactly as accurate as a
text-only one. When the upstream reports nothing, the two capabilities
diverge:
- Chat counts each image content part with a flat per-image estimate
(85 tokens at
"detail": "low", 765 tokens otherwise) instead of counting it as zero. See Vision (image input). - Embeddings still estimates image parts as zero tokens when the
upstream reports no usage; this undercounts image-heavy requests on a
no-usage upstream, and a per-image heuristic for embeddings is a backlog
item. Media volume itself (count and decoded bytes) is tracked
separately via
lumen_media_total/lumen_media_bytes_totaland theusage_logmedia_count/media_bytescolumns, not through the token counters. See Multimodal embeddings.
Either way, a locally-estimated count is always flagged "estimated": true
- the client is never told a number is measured when it is not.
Cost
Per-model prices feed cost accounting and hard budgets:
[[providers.models]]
id = "gpt-4o"
capabilities = ["chat"]
cost_per_1m_input = 2.5
cost_per_1m_output = 10.0
Rerank models price by search unit instead:
[[providers.models]]
id = "rerank-english"
capabilities = ["rerank"]
cost_per_1k_searches = 2.0
A model without prices set costs 0, so hard budgets never bite on it. See
Keys, quotas & budgets for how cost feeds budget
enforcement.
Metrics & dashboards
GET /metrics exposes every gateway metric in Prometheus text exposition
format. It is unauthenticated by design - restrict it at the network layer
(firewall, reverse proxy, service mesh) rather than expecting the gateway
to gate it. See SECURITY.md.
Metrics reference
| Metric | Labels | Meaning |
|---|---|---|
lumen_tokens_total | capability, model, provider, direction, estimated | Tokens processed, cumulative. direction is input/output; estimated is true/false (ADR 003). |
lumen_tokens_estimated_total | none | Subset of the above that was locally estimated rather than upstream-reported. |
lumen_token_breakdown_total | capability, model, provider, kind | Upstream-reported token breakdown, a subset of lumen_tokens_total: kind is cached, reasoning or cache_write (issue #99). Never estimated. See Token accounting. |
lumen_rerank_search_units_total | model, provider | Rerank search units, upstream-reported when available. |
lumen_media_total | capability, model, provider, media_type | Media items (images, …) processed - a billing dimension alongside tokens (M9). |
lumen_media_bytes_total | capability, model, provider, media_type | Decoded media bytes processed (M9). |
lumen_virtual_model_requests_total | virtual_model, model_used | Requests served through a virtual model (ADR 014), by the virtual id the client sent and the foundation model that served it. Counted for served requests; a direct call to a foundation model is not counted. Virtual ids are operator-defined, so cardinality is bounded. |
lumen_http_request_duration_seconds | method, path, status | Wall time of every HTTP request, including /health and /metrics. path is the matched route template, never the raw URI. Streaming responses count time-to-response-headers. |
lumen_request_duration_seconds | capability, model, provider, status | End-to-end latency of one accounted API call. For streaming chat this covers the full stream, recorded when accounting closes. |
lumen_circuit_state | provider, model | Circuit-breaker state: 0 closed, 1 open, 2 half-open. |
lumen_provider_up | provider | Background health-probe result: 1 up, 0 down (absent = unknown / not probed). |
lumen_usage_log_dropped_total | none | Usage-log entries dropped because the logging channel was full. |
lumen_metadata_rejected_total | none | x-lumen-metadata headers dropped as malformed or out of bounds. |
lumen_config_reloads_total | none | Successful configuration hot reloads. |
lumen_config_reload_failures_total | none | Configuration reloads rejected as invalid; the previous config kept serving. |
lumen_webhook_queued_total | none | Webhook events accepted into the outbound queue (ADR 011), across every configured event kind - key lifecycle included, not only the budget ones. The whole lumen_webhook_* family appears the first time a webhook is enabled, not at boot. |
lumen_webhook_sent_total | none | Webhook events the receiver acknowledged with a 2xx. |
lumen_webhook_dropped_total | none | Webhook events dropped because the queue was full. |
lumen_webhook_retries_total | none | Webhook delivery attempts that failed and were retried with backoff. |
lumen_webhook_dead_total | none | Webhook events abandoned after exhausting their retry budget, or permanently rejected by the receiver. |
lumen_webhook_delivery_seconds | none | Wall time of a single webhook delivery attempt. |
lumen_usage_events_pending | none | Billing usage events in the outbox and not yet acknowledged (ADR 015). Only registered when [usage_events] is configured. See Lab integration. |
lumen_usage_events_oldest_pending_seconds | none | Age of the oldest unacknowledged billing usage event, in seconds (0 when none). |
lumen_usage_events_delivered_total | none | Billing usage events the control plane acknowledged in accepted. |
lumen_usage_events_failed_total | reason | Billing usage events whose delivery attempt failed, one increment per event; they stay pending and are retried; see lumen_usage_events_dropped_total for the one exception. not_accepted counts only the events that will be retried: an event dropped on that attempt counts in lumen_usage_events_dropped_total instead. reason is connect, timeout, auth, status, malformed, not_accepted or store. |
lumen_usage_events_dropped_total | none | Billing usage events the control plane kept out of accepted for 24 h, counted from its first 2xx answer that left the event out, dropped with an error log naming the event id (platform contract v2). A skip after a long outage starts the clock; it never drops on its own. The row stays in usage_outbox with dropped_ms set until the 7-day purge. An unreachable or failing control plane never drops an event. |
lumen_tokens_total, lumen_rerank_search_units_total, lumen_media_total
and lumen_media_bytes_total also gain one extra label per key listed in
telemetry.metadata_labels - see
Usage log & multi-tenant metadata for the allowlist
mechanics and a multi-tenant query example.
Per-request cost is not a Prometheus series: it is a usage_log-only
figure, computed from the cost_per_1m_* / cost_per_1k_searches prices in
config. See Token accounting & cost.
What to alert on
Several failure modes are deliberately silent on the request path: the gateway sheds work instead of failing requests, and the only trace is a counter. “The drop is visible in your dashboards” is only true if something watches these:
| Alert condition | Meaning |
|---|---|
increase(lumen_usage_log_dropped_total[5m]) > 0 | Usage-log rows are being shed under pressure: token accounting is incomplete for those requests. Raise usage_channel_capacity or investigate DB write latency. |
increase(lumen_config_reload_failures_total[15m]) > 0 | A config reload was rejected; the old config keeps serving. The deploy that “went out” did not. |
increase(lumen_webhook_dropped_total[5m]) > 0 | The webhook queue is full and signals are being shed: a billing backend relying on budget.threshold for auto-recharge is running blind, and key-lifecycle events are being lost too. Raise channel_capacity or fix a slow receiver. |
increase(lumen_webhook_dead_total[15m]) > 0 | Events are being abandoned - the receiver is down past the retry budget, or rejecting deliveries outright (check for a signature mismatch). Reconcile through GET /admin/usage/export until it recovers. |
lumen_usage_events_oldest_pending_seconds > 900 for 5m | Billing usage events are not reaching the control plane (unreachable, rejecting, or skipping them). Serving is unaffected, but every bill is delayed; check lumen_usage_events_failed_total by reason. See Lab integration. |
increase(lumen_usage_events_dropped_total[1h]) > 0 | A reachable Lab kept leaving a usage event out of accepted for 24 h and the gateway dropped it: that usage was never billed. Reconcile it by hand before the 7-day purge, see the runbook in Lab integration. |
lumen_circuit_state == 1 for 2m | A provider/model circuit is open: calls are short-circuited to fallbacks (or failing). |
lumen_provider_up == 0 for 5m | A background health probe cannot reach a provider (only exported for probed providers). |
5xx share of lumen_request_duration_seconds_count > 5% | Upstream or gateway failures; 499 (client cancelled) is deliberately outside the 5xx class and does not count. |
The monitoring rig ships these as ready-made Prometheus rules in
monitoring/prometheus/alerts.yml -
copy them into your own Prometheus and tune the windows. Routing them to a
pager needs an Alertmanager, which the rig intentionally leaves out.
See it live: the monitoring rig
monitoring/ is a one-command Docker Compose stack that runs the gateway
against real providers with Prometheus scraping it and a pre-provisioned
Grafana dashboard covering every metric above: token rates by
provider/model/capability/direction, rerank search units, media accounting,
circuit-breaker state and the internal counters. ./smoke.py exercises
chat, streaming, embeddings and rerank per provider and asserts a non-zero
token count on each; ./traffic.py generates sustained randomized traffic,
tagged with multi-tenant metadata, so the dashboard has something to show.
It is the fastest way to see all of this live - see monitoring/README.md.
Logging
Logs are the third observability leg next to metrics and the usage log. The gateway writes structured logs to stdout only (no file appender, no rotation of its own - that is the supervisor’s or the log pipeline’s job).
What is never logged
Prompts, responses and provider secrets are treated as radioactive:
- Request and response content is never logged, at any level,
including
debugandtrace. This is the sovereignty pillar, not a default you can toggle. The nearest thing is an upstream’s own error message, which is logged only after request echoes are cut out of it (see Upstream error messages). - Provider API keys and the master key never appear in logs or errors;
the redacting
Debugimplementations keep them out of debug output, and tests enforce it.
What is logged is metadata: request ids, models, providers, HTTP status,
latency, operational events (boot, config reloads, circuit-breaker
transitions, flush failures), the upstream’s own error message on a failed
call (below), and the client-supplied x-lumen-metadata
header when present (a bounded JSON object of up to 16 keys, whole header
capped at 4 KiB). Operators must not include secrets or prompt content in
the metadata header, as it is logged in full and stored in the usage_log
table.
Upstream error messages
When an upstream answers a client error (4xx other than 429) whose JSON
body carries a message string, the gateway logs one warn line,
upstream returned an error, with provider, status and
upstream_error, under the request span (same request_id as the
request failed line that follows). For example, a 400 from Anthropic
carries Anthropic’s error.message. Without such a string there is no
line, only the status in request failed. The rules (ADR 016):
- Only the vendor’s message string is logged (
error.message,message,error_description,error,detail). A body without one, a non-JSON body, or a structured validation report is never logged, since it can quote the request. - The message is cut where a validation error starts echoing the request
(
'input',"input",input_value,input=, as pydantic-based hosts such as vLLM do), so request content does not reach the log. - Probable credentials are replaced by
<redacted>(known key prefixes, long opaque tokens, and the value printed after a word such askey,tokenorpassword), then the message is cut to 512 characters. - It never reaches the client. The client gets the usual error envelope:
LM-2012orLM-2013when the body classifies as a context-length or content-policy refusal,LM-3003otherwise.
To drop the line entirely, filter its target at the subscriber, for example
RUST_LOG=info,lumen_providers::mapping=error.
Format
log_format is a top-level config key:
log_format = "json" # "pretty" (default) or "json"
pretty: human-readable, colored, for local development.json: one JSON object per line with event fields flattened, for production pipelines (Loki, CloudWatch, journald + a parser, …).
The format is boot-time only (logging initializes before anything else runs, right after config parses).
Level and filtering: RUST_LOG
Filtering uses the standard tracing env-filter syntax via the RUST_LOG
environment variable. When RUST_LOG is unset, the gateway runs at
info.
RUST_LOG=debug lumen --config config.toml # everything, verbose
RUST_LOG=warn lumen --config config.toml # quiet: warnings and errors
RUST_LOG="info,lumen=debug,lumen_server=debug" lumen ... # gateway binary + HTTP layer
RUST_LOG="info,lumen_providers=debug" lumen ... # debug for provider translation
Targets follow Rust module paths: the binary crate is lumen (its
per-request events use the lumen::http and lumen::usage targets); the
libraries are lumen_server, lumen_providers, lumen_router,
lumen_auth, lumen_telemetry, lumen_core (ADR 001 naming).
Where debug is specifically useful: dropped OpenAI extras on
translated providers. In lenient mode, a request field with no
equivalent on the target provider (see
chat completions) is dropped with a debug
line naming the field. If a client swears it sent logprobs and nothing
happened, this is where the evidence is.
Rejected metadata needs no debug: malformed x-lumen-metadata never
fails a request; it is dropped with a warn line (visible at the default
level) and a lumen_metadata_rejected_total increment - see the
usage log.
Docker and systemd
Both capture stdout natively:
docker logs <container> # or a logging driver
journalctl -u lumen.service -f # systemd unit from the deployment page
For production pipelines set log_format = "json" (the
monitoring rig runs this way)
and let the collector parse one object per line.
Usage log & multi-tenant metadata
When [auth] is enabled, every request writes a row to the usage_log
table: token counts, cost, status, the model that actually served the
request, and metadata - never message content. Prompts and responses are
never logged, by default and in the usage log alike.
The route column
model is the id the client requested and model_used the foundation model
that served. When the requested id is a virtual model,
the nullable route column (migration 0010) also records the path the
request took, for example acme/chat>acme/chat-eu>mistral-large: the same
value as the x-lumen-route response header. It is NULL for a direct call
to a foundation model and for rows written before the column existed.
Never on the request path
Usage-log writes never block a request. Each accounted call pushes an entry
onto a bounded async channel (usage_channel_capacity); a separate batched
writer task drains it and writes to the database in batches
(usage_batch_max entries, or at least every usage_flush_ms). If the
channel is ever full, an entry is dropped rather than backpressuring the
request path, and lumen_usage_log_dropped_total increments so the drop is
visible on /metrics.
Rows age out on their own: retention_days purges usage_log rows older
than that many days.
Querying it over HTTP
GET /admin/usage (master-key gated, like every /admin/* route) returns
aggregates over these rows - filtered by key, budget group (ADR 009),
model, provider, capability and time window, grouped by the dimension you
choose. Every row carries the key’s group_id at admission (refusal rows
included), so per-pool reporting covers refused traffic too. Because rows arrive
through the bounded channel above, requests from the last flush interval
may not be visible yet. See
Keys, quotas & budgets.
Exporting raw rows: GET /admin/usage/export
GET /admin/usage aggregates over one dimension at a time. A control plane
building its own multi-dimensional view (per-tenant AND per-model AND
per-day, say) needs the underlying rows instead of a fixed aggregate shape
(ADR 010). GET /admin/usage/export returns them directly, cursor-paginated,
master-key gated like every other /admin/* route.
Query parameters (all optional, unknown parameters are rejected with 400
LM-1001):
| Parameter | Meaning | Default |
|---|---|---|
since | Window start (inclusive): unix seconds or RFC3339. | 24 hours before until |
until | Window end (inclusive): unix seconds or RFC3339. | now |
cursor | Return rows with id strictly greater than this. | start of the window |
limit | Page size, 1 to 10000. | 1000 |
The response shape:
{
"since": 1785600000,
"until": 1785686400,
"rows": [ { "id": 1042, "key_id": "...", "model": "...", "...": "..." } ],
"next_cursor": 1043
}
since and until are the EFFECTIVE window for this call: either what the
caller passed, or the resolved default (24 hours before until, until
itself defaulting to “now”). Each row has the same columns as the
usage_log table (id, key_id, group_id, model, model_used, route, provider,
capability, token/media/cache counters, cost, latency, status, metadata,
ts). As with every other usage surface, rows never carry prompt or response
content, only accounting fields.
Pagination is by primary key, not offset, so an export cannot skip or repeat
a row when new requests land mid-export: keep requesting with cursor set
to the previous page’s next_cursor until next_cursor comes back null,
which signals the window is exhausted. A full page is not by itself proof
that more data exists; the exhausted signal is always a null cursor, even
if that means one extra call returning zero rows at the very end.
Pin the window explicitly when paginating. With no explicit since/
until, both default relative to “now” and are recomputed independently on
every call - so a multi-page export that never passes since/until is
filtering each page against a window that keeps sliding forward while it
pages through. Read since and until off the FIRST response and pass
those same two values back on every subsequent page, instead of relying on
the defaults again; this is exactly why the response echoes them.
limit is capped at 10000 per page; a request above the cap is rejected
(400) rather than silently clamped, so a caller always knows the page it got
back was the size it asked for.
curl -s -H "Authorization: Bearer $LUMEN_MASTER_KEY" \
"http://localhost:8080/admin/usage/export?since=2026-08-01T00:00:00Z&limit=500"
x-lumen-metadata
Clients may attach a per-request metadata header, canonically
x-lumen-metadata (alias cf-aig-metadata, for drop-in compatibility with
Cloudflare AI Gateway clients). The value is a flat JSON object of
string/number/bool values, bounded so log records and memory stay bounded:
at most 16 keys, each key at most 64 bytes, each value at most 256 bytes,
and the whole header at most 4 KiB.
The full (bounded) object is attached to structured logs and stored in the
usage_log metadata column for later filtering. It is opaque - LUMEN
never parses it for meaning or PII - and it is logged, so it must never
carry secrets or prompt content.
Missing, malformed, oversized or wrong-typed metadata never fails the
request: it is dropped with a log line and a lumen_metadata_rejected_total
increment, and the call proceeds normally. See
ADR 002.
Prometheus label allowlist
Only keys listed in telemetry.metadata_labels become Prometheus labels on
the token/media counters; every other key stays logs-only:
[telemetry]
metadata_labels = [] # e.g. ["team", "env"]
The default is empty, which means client-supplied metadata can never mint a
single new Prometheus time series - metric cardinality is a deliberate,
operator-bounded decision, never a client-driven one. An allowlisted key
absent from a given request gets the label value "". Keep the value sets
you allowlist bounded: every distinct combination of allowlisted values is
its own time series.
Multi-tenant recipe
Send org/team/project identifiers in the metadata header, allowlist the
keys you want to slice by, and query per tenant on /metrics. With [auth]
enabled, requests also need a virtual key:
curl http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer $LUMEN_KEY" \
-H 'content-type: application/json' \
-H 'x-lumen-metadata: {"org_id":"acme","team_id":"rag","project_id":"docs-chat"}' \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
[telemetry]
metadata_labels = ["org_id", "team_id", "project_id"]
Tokens per organization over the last 24 hours:
sum by (org_id) (increase(lumen_tokens_total{org_id!=""}[24h]))
The monitoring/ rig’s traffic.py script simulates exactly this pattern
across several tenants and its dashboard has a dedicated multi-tenant panel
row - see monitoring/README.md.
Disconnect accounting
A client that disconnects mid-stream is not a gateway failure and must not
be recorded as a fake success. The request’s accounting settles at 499
(LM-6001) instead: usage_log.status and the
lumen_request_duration_seconds{status="499"} sample both reflect the
disconnect, kept out of both the internal-error class and the 5xx
status class so a client hanging up never inflates internal-error alerts.
See Error codes.
Keys, quotas & budgets
Auth is off by default: with [auth].enabled = false the gateway is an
open proxy, with no database at all. Turn it on to get virtual keys, hard
budgets and RPM/TPM quotas.
Enabling
[auth]
enabled = true
db_path = "lumen.db" # SQLite file, created if missing
When enabled, the LUMEN_MASTER_KEY environment variable is required:
64 hex characters (32 bytes). It serves two roles - the bearer token of the
/admin/* API, and the AES-256-GCM key that seals provider keys stored at
rest. It is never logged and never stored.
What you get
With auth on, every /v1/* request is checked against a virtual key:
- Virtual keys are stored only as BLAKE3 hashes; the plaintext is returned exactly once, at creation, and never again.
- Hard budgets (
budget_max, in USD) and RPM/TPM quotas (rpm_limit,tpm_limit) are enforced in memory, before any upstream call - a rejected request never spends and never reaches a provider. - The database is never on the request path. Budget spend is flushed
from memory to SQLite on
flush_interval_ms(default 10000). While the process is running, enforcement itself lives in memory ahead of the flush, so budget overruns cannot occur. A crash loses at most that much accounting; after a restart, budgets reload from the last persisted state, so unflushed usage lost in a crash can permit spend beyond the intended budget until the gap closes. - That database is the only copy of the key hashes, stored provider keys, budget state and usage ledger: see Backups for how not to lose it, and Scaling and high availability for why in-memory enforcement means one instance in v1.
Refusals
| Code | HTTP | Cause |
|---|---|---|
LM-4001 | 402 | Hard budget exhausted - the key’s own budget or its budget group’s shared pool. Same code for both; the message text says which scope refused. |
LM-4002 | 429 | Requests-per-minute quota exceeded. |
LM-4003 | 429 | Tokens-per-minute quota exceeded. |
LM-4004 | 401 | Missing or invalid virtual key - deliberately unspecific: unknown, disabled and expired keys are indistinguishable, so a caller cannot probe key state. |
See Error codes for the full taxonomy.
Bootstrapping the first key (CLI)
On a fresh deployment there is no usable client key yet, and the admin API
requires a running server. lumen keys closes that loop: it runs offline,
straight against the SQLite file at auth.db_path - no server needed.
export LUMEN_MASTER_KEY=<64 hex chars> # same gate as the /admin API
lumen keys create --config config.toml --name team-search \
--budget-max 50 --rpm-limit 60 --tpm-limit 100000
lumen keys list --config config.toml
keys create prints the record plus the one-time plaintext key as a JSON
object on stdout (the same shape as POST /admin/keys, shown below) and never
logs it. --budget-max, --rpm-limit, --tpm-limit and --expires-at are
optional, exactly like their JSON counterparts; keys list prints the records
only (no hashes, no plaintext).
If the server is already running, keys list is safe, but a key created by
the CLI only joins the live in-memory key table at the next restart or config
reload - prefer POST /admin/keys against the running server in that case.
The admin API
Every route under /admin/* is mounted only when [auth].enabled = true,
and every route is gated by the master key (Authorization: Bearer <LUMEN_MASTER_KEY>). Changes apply to the database and the in-memory state
together, so they take effect immediately with no restart.
| Method | Path | Purpose |
|---|---|---|
POST | /admin/keys | Create a virtual key. |
GET | /admin/keys | List active keys (records only - no hashes, no plaintext). ?include_deleted=true adds tombstones; ?external_ref=... filters. |
PATCH | /admin/keys/{id} | Adjust budget/limits, or enable/disable a key. |
DELETE | /admin/keys/{id} | Soft-delete a key (tombstone; stops authenticating immediately). |
POST | /admin/keys/{id}/rotate | Mint a new secret for an existing key (one-time plaintext, identity and spend preserved). |
POST | /admin/keys/{id}/grant | Atomically add to a key’s budget cap (concurrency-safe top-up). |
POST | /admin/groups | Create a budget group - a shared pool member keys draw from. |
GET | /admin/groups | List active groups. ?include_deleted=true adds tombstones; ?account_ref=... filters. |
PATCH | /admin/groups/{id} | Adjust a group’s name or shared budget (pool spend preserved). |
DELETE | /admin/groups/{id} | Soft-delete a group. Refused while it still has active member keys. |
POST | /admin/groups/{id}/grant | Atomically add to a group’s shared budget cap (concurrency-safe top-up). |
PUT | /admin/provider-keys/{name} | Store a provider API key encrypted at rest. |
POST | /admin/providers/{name}/check | Check a provider’s live credentials without spending tokens. |
GET | /admin/usage | Aggregated usage and spend from the usage log. |
Budget groups
A budget group is a shared pool that several keys draw from (ADR 009). The pattern it exists for: prepaid credits per customer, one key per project of that customer. Without groups you would chunk the customer’s credit across the project keys and rebalance from outside - racy, and credit strands on idle keys. With groups: one group per customer, its keys join it, and a top-up is a single grant.
Create the pool first:
curl -s http://localhost:8080/admin/groups \
-H "Authorization: Bearer $LUMEN_MASTER_KEY" \
-H 'content-type: application/json' \
-d '{"name": "acme-corp", "budget_max": 500.0}'
name is required; budget_max (USD) is optional - omit it for an
unlimited pool that only attributes usage. A group has no plaintext and no
hash, so unlike key creation there is no one-time secret: the 201
response is just the record, and GET /admin/groups lists the same shape.
{
"id": "...",
"name": "acme-corp",
"budget_max": 500.0,
"budget_spent": 0.0,
"created_at": 1752537600,
"deleted_at": null
}
A group carries budget only: no group-level RPM/TPM, no disabled
flag, no expiry (each member key’s own limits still apply; group-level
limits are backlog).
Membership. A key belongs to at most one group, via group_id on
POST /admin/keys or PATCH /admin/keys/{id}:
curl -s http://localhost:8080/admin/keys \
-H "Authorization: Bearer $LUMEN_MASTER_KEY" \
-H 'content-type: application/json' \
-d '{"name": "acme-project-search", "group_id": "<group id>", "budget_max": 100.0}'
On the patch, group_id is tri-state: leave the field out and
membership is unchanged, send JSON null and the key leaves its group,
send a string and it joins that group. This is the one deliberate
exception to “patch fields cannot clear to NULL” - leaving a group must
not require re-minting the key. A group_id naming an unknown or deleted
group is refused with 400 LM-1001 before anything is written. Key
records expose group_id wherever records appear. lumen keys create
takes --group-id <ID> too, but the group must already exist: there is no
offline lumen groups subcommand - create groups through the admin API,
which needs only the master key.
Enforcement works exactly like a key budget, one level up:
- Admission checks both. The key’s own
budget_max(when set) AND the group pool, in memory, before any upstream call. Either refusal is a 402LM-4001; the message says which scope refused (“budget exceeded for this key” vs “budget exceeded for this key’s group”). - Refunds mirror per-key budgets (ADR 007): a request refused at admission consumes nothing from either pool, and actual cost settles against both on completion.
- Flush and crash semantics are identical to key budgets: pool spend
flushes to SQLite on
flush_interval_ms, enforcement lives in memory ahead of the flush, and a crash loses at most one flush interval of pool accounting.
Pool spend is the group’s own accumulator, never recomputed from its members: spend a key accrued before joining (or after leaving) is not moved retroactively.
To top up a customer, raise the cap - pool spend is preserved, and the new
cap binds every member key on its next request, no restart (name can be
patched the same way; an unknown or deleted id returns 404 LM-1003):
curl -s -X PATCH http://localhost:8080/admin/groups/<id> \
-H "Authorization: Bearer $LUMEN_MASTER_KEY" \
-H 'content-type: application/json' \
-d '{"budget_max": 1000.0}'
A PATCH sends an absolute cap, so top-ups that can race (an
automated billing flow) should use
grant instead - it adds atomically and never
loses a concurrent update.
DELETE /admin/groups/{id} is a soft delete, like keys, and it is
refused (400) while the group still has active member keys: move them out
(PATCH with "group_id": null or another group) or delete them first.
Silently dropping members out of pool enforcement would be worse than the
error. The tombstone keeps usage_log attribution, and its final pool
spend is flushed on removal.
Attribution. Every usage_log row - successes and 402/429 refusal
rows alike - carries the key’s group id (when it has one), so per-customer
reporting includes the traffic the pool refused. GET /admin/usage
(below) takes a group_id filter and group_by=group_id; under that
grouping, rows from ungrouped keys aggregate under an empty group name.
Lab-linked groups
A control plane such as the Meilisearch Lab marks the groups and keys it owns with two opaque fields (ADR 015):
account_refon a group (POST/PATCH /admin/groups): the control plane’s account id. A key in a group that has anaccount_refis billable.external_refon a key (POST/PATCH /admin/keys): the control plane’s key id.
Both are 1 to 128 characters when set, PATCH with null clears them, and
both are returned by GET and filterable on the list routes
(GET /admin/groups?account_ref=..., GET /admin/keys?external_ref=...).
While [usage_events] is configured, account_ref must also be a UUID
(400 LM-1001 otherwise).
With [usage_events] on, PATCH /admin/keys/{id} with group_id and
PATCH /admin/groups/{id} with account_ref first flush pending spend, so it
is billed to the account in force when it was spent. If that flush fails the
call answers 500 LM-5001 and changes nothing: retry it. A database outage
therefore blocks key moves and account_ref changes while billing is on.
See Lab integration for the billing rule, event format
and delivery.
Granting budget (top-ups)
POST /admin/groups/{id}/grant adds to a pool’s cap atomically. It
exists for the prepaid-credits flow above: a customer buys credits, and
the billing control plane grants their pool.
curl -s -X POST http://localhost:8080/admin/groups/<id>/grant \
-H "Authorization: Bearer $LUMEN_MASTER_KEY" \
-H 'content-type: application/json' \
-d '{"amount": 25.0}'
The body is {"amount": <USD>}, the amount to add to budget_max;
spend and quota windows are untouched. The 200 response is the updated
group record, the same shape as GET /admin/groups. The key half is
POST /admin/keys/{id}/grant: same body, same rules, and the 200
response is the updated key record (the record only - no plaintext,
nothing was minted).
Why not a PATCH? A patch sends an absolute cap, so two racing
top-ups can lose one: both workers read 500, both add 25, both write 525,
and one customer payment vanishes. A grant is an atomic increment on
both sides - budget_max = budget_max + ? inside SQLite, a
fetch_add on the live in-memory entry - so concurrent grants all land.
Like every admin change it takes effect on the very next request, no
restart.
Three refusals:
- A non-positive, non-finite or oversized
amount(400LM-1001). Zero and negatives are rejected; overflowing literals like1e999never even parse; and a single grant is capped at1e12USD so repeated grants can never sum the stored cap toward infinity (which would read back as unlimited). - Unknown or deleted id (
404LM-1003), like every other admin write. - A capless target (
budget_maxnull,400LM-1001): there is no cap to raise, and silently doing nothing would be worse than the error. Set a cap first (PATCH {"budget_max": ...}), then grant.
One retry caveat for billing automation: a grant is not idempotent. A
call that timed out or disconnected may still have landed in the
database, and blindly retrying would credit the cap twice - verify with
GET /admin/groups (or /admin/keys) before retrying.
Create a key
curl -s http://localhost:8080/admin/keys \
-H "Authorization: Bearer $LUMEN_MASTER_KEY" \
-H 'content-type: application/json' \
-d '{
"name": "team-search",
"budget_max": 50.0,
"rpm_limit": 60,
"tpm_limit": 100000
}'
name is required; budget_max, rpm_limit, tpm_limit and expires_at
(unix seconds) are all optional - omit any of them for “unlimited”. A blank
name, a negative value, or a budget_max above 1e12 USD is refused with
400 LM-1001 naming the field (0 is accepted: a key that may spend or send
nothing). Group budgets follow the same rule. Names are labels, not
identifiers: two keys may share one, so look keys up by id. The
response is the only place the plaintext key ever appears:
{
"key": "sk-lumen-...",
"id": "...",
"name": "team-search",
"group_id": null,
"budget_max": 50.0,
"budget_spent": 0.0,
"rpm_limit": 60,
"tpm_limit": 100000,
"expires_at": null,
"disabled": false,
"created_at": 1752537600,
"deleted_at": null
}
Store key now - it is never shown again. PATCH /admin/keys/{id} takes
the same budget/quota fields (plus disabled) to adjust an existing key;
fields left out of the patch are unchanged, the same value rules as on
creation apply, and an unknown id returns 404 LM-1003.
Rotate a key (POST /admin/keys/{id}/rotate)
A lost or leaked key does not have to be replaced by a new one: rotation mints a fresh secret for the same key record.
curl -s -X POST http://localhost:8080/admin/keys/<id>/rotate \
-H "Authorization: Bearer $LUMEN_MASTER_KEY"
The response has the exact same shape as creation - the new plaintext in
key, shown exactly once, plus the record. Everything else is preserved:
the id (so usage_log attribution is unbroken), the name, budgets,
accrued spend and quotas. The swap is applied to the in-memory table too,
so the old plaintext stops authenticating on the very next request and the
new one works without a restart. An unknown or deleted id returns 404
LM-1003.
Delete a key (DELETE /admin/keys/{id})
curl -s -X DELETE http://localhost:8080/admin/keys/<id> \
-H "Authorization: Bearer $LUMEN_MASTER_KEY"
Returns 204 No Content. Deletion is a soft delete by design:
usage_log rows reference the key id, so removing the row would orphan the
key’s usage history. Instead the row is tombstoned (deleted_at is set) and
kept for attribution and audit. A deleted key:
- stops authenticating immediately (the in-memory table is updated, no restart needed), and never loads again at boot;
- disappears from
GET /admin/keys; pass?include_deleted=trueto list tombstones (the audit view); - rejects any further
PATCH,DELETEor rotate with 404LM-1003, like an unknown id - it cannot be resurrected by accident.
Retention of the tombstoned rows follows your usage-log retention policy:
they are plain rows in the same database, with no plaintext and no hash
that still authenticates. If you only want to pause a key, use
PATCH {"disabled": true} instead - that one is reversible.
Store a provider key (PUT /admin/provider-keys/{name})
curl -s -X PUT http://localhost:8080/admin/provider-keys/openai \
-H "Authorization: Bearer $LUMEN_MASTER_KEY" \
-H 'content-type: application/json' \
-d '{"key": "sk-..."}'
{name} is the provider’s configured name (as in [[providers]]). The
body is {"key": "<provider api key>"}; a successful call returns 204 No Content. The key is sealed with AES-256-GCM under LUMEN_MASTER_KEY before
it touches disk, for providers whose api_key_env is unset or empty. The
call pings the hot-reload trigger after sealing the key, so the reloader
re-reads provider keys from the encrypted store (off the request path) and
rebuilds the provider registry - a rotated key takes effect without a
restart. Environment-sourced keys keep precedence over a stored key. See
Deployment - Hot reload.
{name} must be a provider the current config document defines; any other
name is refused with 404 LM-1003 and nothing is stored (a provider added
with PUT /admin/config/providers/{name} can take its key right after that
call returns). DELETE /admin/config/providers/{name} also forgets the
provider’s stored key, so a provider recreated later under the same name
starts without one.
Check a provider key (POST /admin/providers/{name}/check)
Verifies a configured provider’s live credentials (the ones the routing
table uses right now, so a key just rotated with PUT /admin/provider-keys/{name} is the one checked) without running a chat,
embedding or rerank call. The gateway sends one request to a free,
authenticated endpoint of that provider kind and never an inference route,
so no tokens are spent.
curl -s -X POST http://localhost:8080/admin/providers/openai/check \
-H "Authorization: Bearer $LUMEN_MASTER_KEY"
{
"provider": "openai",
"kind": "openai",
"key_valid": true,
"reachable": true,
"http_status": 200,
"latency_ms": 142,
"endpoint": "GET https://api.openai.com/v1/models",
"detail": "key accepted"
}
key_valid is a tri-state:
| Value | Meaning |
|---|---|
true | The upstream accepted the credentials. |
false | The upstream rejected them (401/403), or none are configured (then reachable is null: no request was made). |
null | This check cannot tell: a keyless provider, a kind with no free check endpoint, a rate limit (429), a 404, a 5xx, or an unreachable host (reachable: false). |
The verdict is data: the call answers 200 whatever the key’s state, so a
rejected provider key is never mistaken for a rejected master key. An
unknown {name} is 404 LM-1003. The response never contains the key,
and endpoint drops any user:password@ a base_url carries. Each check
is capped at 10 seconds.
What each kind probes:
| Kind | Check request |
|---|---|
openai, groq, together, fireworks, deepseek, xai, deepinfra, mistral, vllm (with a key) | GET {base_url}/models |
openrouter | GET {base_url}/key (its model list is public, so it would pass any key) |
huggingface | GET https://huggingface.co/api/whoami-v2, or GET {base_url}/models with a custom base_url |
cloudflare | GET {account}/ai/models/search?per_page=1 |
anthropic | GET {base_url}/v1/models?limit=1 |
cohere | POST {base_url}/v1/check-api-key (reads its valid flag) |
google | GET {base_url}/v1beta/models?pageSize=1 (a 400 API_KEY_INVALID counts as rejected) |
azure | GET {endpoint}/openai/models?api-version=... |
pinecone | GET {base_url}/indexes |
vertex_ai | Mint a fresh OAuth token from the service account (the cache is bypassed) |
bedrock | SigV4-signed GET /foundation-models on the region’s control plane (bedrock.{region}.amazonaws.com, bedrock-fips. for a FIPS runtime host, .amazonaws.com.cn in China). A VPC runtime endpoint falls back to the public control plane, so from an air-gapped VPC the check reports reachable: false |
tei, ollama, nvidia, vllm without a key | Nothing to check: key_valid: null |
jina, voyage, mixedbread, typesafe, perplexity, nvidia / tei with a key | No free check endpoint is known: key_valid: null, no request |
A passing check proves the credentials authenticate, not that inference
will succeed. These still only show up on a real request: an exhausted
quota or credit balance, a model the key is not entitled to, a missing
Bedrock model-access grant, a wrong Azure deployment name, and RPM limits.
For Bedrock, an AccessDeniedException on the listing still reports
key_valid: true (the credentials authenticated; the IAM policy just lacks
bedrock:ListFoundationModels), with a detail saying so.
Usage & spend reporting (GET /admin/usage)
Aggregates the usage log per key, budget group, model, provider or capability - the HTTP query surface over the same rows the batched writer persists:
curl -s "http://localhost:8080/admin/usage?group_by=provider&since=2026-07-15T00:00:00Z" \
-H "Authorization: Bearer $LUMEN_MASTER_KEY"
Query parameters (all optional):
| Parameter | Meaning | Default |
|---|---|---|
key_id | Only rows for this virtual key id. | all keys |
group_id | Only rows attributed to this budget group id. | all rows |
model | Only rows for this client-facing model id. | all models |
provider | Only rows attributed to this provider instance. | all providers |
capability | chat, embed, rerank, decisions or systemone. decisions and systemone are the same filter and match both: rows written before the rename are stored as systemone, newer ones as decisions. With group_by=capability the old rows still show as systemone and the new ones as decisions. | all capabilities |
since | Window start (inclusive): unix seconds or RFC3339. | until - 24 h |
until | Window end (inclusive): unix seconds or RFC3339. | now |
group_by | model, model_used, provider, capability, key_id, group_id, status or total. | model |
limit | Maximum groups returned, 1 to 1000. | 100 |
The response echoes the effective window and grouping, then one aggregate per group - request counts split by status class, token totals, the estimated-vs-upstream split (ADR 003), rerank search units, media counts and cost:
{
"since": 1784073600,
"until": 1784160000,
"group_by": "provider",
"truncated": false,
"groups": [
{
"group": "openai",
"requests": 1204,
"requests_ok": 1180,
"requests_client_error": 20,
"requests_server_error": 4,
"tokens_in": 803211,
"tokens_out": 121408,
"tokens_total": 924619,
"estimated_requests": 17,
"upstream_requests": 1187,
"search_units": 0,
"media_count": 3,
"media_bytes": 402133,
"cost": 12.41
}
]
}
Groups are ordered by cost (highest first) and capped at limit; when more
groups matched, truncated is true and the returned groups are the most
expensive ones. A window that matches nothing is a normal 200 with an
empty groups array. Invalid filters, timestamps, group_by values or
limits are 400 LM-1001.
Two accounting notes:
- Recent requests may lag. Usage rows travel through the bounded
channel and its batched writer (see
Usage log), so requests from
the last flush interval (
usage_flush_ms, default 2 s) may not appear yet - and entries dropped under a jammed channel (lumen_usage_log_dropped_total) never will. upstream_requestscounts rows whose numbers are exact, which includes admission refusals (402/429): they consumed zero tokens, and zero is exact.estimated_requestscounts rows whose token counts were locally estimated per ADR 003.- Provider attribution on refusals. Rows served by a provider carry the provider that actually served them (under a fallback this may differ from the primary). Admission-refusal rows (402/429) never reached a provider; they carry the requested model’s primary provider, so per-provider reports still see the traffic that was headed there.
One encoding note: an RFC3339 +HH:MM offset contains a +, which in a
query string means a space - percent-encode it as %2B
(until=2026-07-15T03:00:00%2B02:00), or use Z/unix seconds.
Outbound webhooks for budget events
Everything above is pull: a control plane asks LUMEN what happened.
Webhooks are the push half (ADR 011), and they exist for one problem in
particular. A hard budget refuses with 402 LM-4001 the instant the
pool empties, so a prepaid-credits backend that only polls will always
learn about the exhaustion after the customer has been refused. A
budget.threshold event at 80% is the trigger for an auto-recharge that
lands as a POST /admin/keys/{id}/grant before that ever happens.
Absent by default. With no webhook configured, LUMEN makes no outbound
call to anything but its providers, and does not even export a
lumen_webhook_* metric. Enabling it is a deliberate choice, and it
requires auth.enabled = true (every event describes a virtual key or a
budget group).
There are two ways to configure one, and they compose: the admin API for a control plane, and the config file for a GitOps deployment.
Creating a webhook through the API
This is the path a billing backend wants: nothing to restart, nothing to
edit on the host. Three calls, all gated by LUMEN_MASTER_KEY.
1. Store a signing secret. Generate one, keep your copy - LUMEN seals it and will never hand it back.
export WEBHOOK_SECRET="whsec_$(openssl rand -hex 24)"
# The secret reaches curl on stdin, never in an argument list (any local
# process can read those), and `jq` builds the JSON so a secret containing a
# quote or a backslash cannot corrupt the body. `printf` is a shell builtin,
# so it does not put the value in the process table either.
printf '%s' "$WEBHOOK_SECRET" | jq -Rs '{secret: .}' |
curl -s -X PUT http://localhost:8080/admin/webhooks/signing-key \
-H "Authorization: Bearer $LUMEN_MASTER_KEY" \
-H 'content-type: application/json' \
--data-binary @-
204. The secret is encrypted with AES-256-GCM under the master key
before it touches the disk, exactly like a stored provider key.
2. Create the webhook.
curl -s -X PUT http://localhost:8080/admin/webhooks \
-H "Authorization: Bearer $LUMEN_MASTER_KEY" \
-H 'content-type: application/json' \
-d '{
"url": "https://backend.example.com/lumen/events",
"events": ["budget.threshold", "budget.exhausted", "key.disabled"],
"thresholds": [50, 80, 95],
"channel_capacity": 1024,
"timeout_ms": 5000,
"max_attempts": 5,
"retry_base_ms": 500
}'
url is the only required field; every other one has the default shown
above. Because this route decides where the gateway sends requests, the
receiver must be publicly routable: a url whose host is a loopback,
private, link-local (for example the 169.254.169.254 cloud metadata
endpoint), unique-local or other reserved address, a localhost name, or a
hostname that resolves to any such address (or does not resolve) is refused
with 400 LM-1001. To deliver to an internal receiver, list its host in the
LUMEN_WEBHOOK_ALLOWED_HOSTS environment variable (comma-separated; an
entry matches exactly, or as a domain and its subdomains when written
.suffix, for example LUMEN_WEBHOOK_ALLOWED_HOSTS=billing.internal,.corp.example),
read once at boot. A listed host is accepted without resolving it. The check
runs when the URL is set, not on every delivery, so it does not cover a
hostname re-pointed afterwards or a receiver that answers with a redirect.
The same check applies when PUT /admin/config/webhooks or a whole-document
PUT /admin/config introduces or changes the [webhooks] receiver; a
receiver already in the document (written by the operator on disk) is not
re-checked on unrelated edits. Note that /admin/config* remains the most
privileged surface: a provider’s base_url is not restricted the same way,
so expose it only to callers you would trust with the host. The 200 response is the new state:
{
"enabled": true,
"source": "database",
"settings": { "url": "https://backend.example.com/lumen/events", "...": "..." },
"signed": true,
"signing_key_stored": true,
"updated_at": 1787691194
}
The new policy is in force from that moment: the very next request that crosses a threshold enqueues an event. Delivery itself stays asynchronous and best-effort, so a full queue or a receiver that stays down past the retry budget still drops it (see the guarantees below). The settings are stored, so a restart comes up identically.
3. Check it.
curl -s http://localhost:8080/admin/webhooks \
-H "Authorization: Bearer $LUMEN_MASTER_KEY"
signed: true is the field to watch. If it is false, deliveries carry no
x-lumen-signature and your receiver cannot tell them from anyone else’s
traffic.
Editing is the same PUT: it replaces every setting, so send the
whole document rather than a fragment (there is no PATCH - a partial
update of a delivery policy is how you end up retrying against a URL you
meant to change). Every field is editable at runtime, channel_capacity
included: LUMEN rebuilds the queue and lets the previous sender drain what
it already had.
Rotating the secret is another PUT /admin/webhooks/signing-key. It
applies to the next delivery attempt, with nothing restarted. Retries of an
event already in flight keep the signature they were created with, so a
rotation never makes a pending retry unverifiable.
Deleting:
curl -s -X DELETE http://localhost:8080/admin/webhooks \
-H "Authorization: Bearer $LUMEN_MASTER_KEY"
Emission stops immediately, and the decision is stored: a [webhooks]
block in the config file will not quietly re-enable it on the next
reload. DELETE /admin/webhooks/signing-key forgets the secret separately.
Declaring a webhook in the config file
For a deployment where the config file is the source of truth and no control plane calls the API:
[webhooks]
url = "https://backend.example.com/lumen/events"
signing_key_env = "LUMEN_WEBHOOK_SECRET"
events = ["budget.threshold", "budget.exhausted", "key.disabled"]
thresholds = [50, 80, 95]
channel_capacity = 1024
timeout_ms = 5000
max_attempts = 5
retry_base_ms = 500
signing_key_env names the environment variable holding the secret; the
secret itself never appears in the file. A named-but-unset variable with no
stored secret is a boot error: a billing integration silently
downgraded to unsigned deliveries is worse than a refused start.
Which one wins. Settings written through PUT /admin/webhooks are
stored in the database and take precedence over this block, so a runtime
change is not undone by the next reload. GET /admin/webhooks reports
source as "database" or "config" so the two are never ambiguous. If
you manage the file, avoid the PUT (or expect the file to become
decorative); if you manage the API, the block is just the boot-time
default.
The events
| Event | Fires when | What a backend does with it |
|---|---|---|
budget.threshold | budget_spent / budget_max crosses a configured percentage | Charge the customer, then grant |
budget.exhausted | The first LM-4001 refusal since the subject last had headroom | Alert, upsell, or suspend cleanly |
key.disabled | A PATCH disabled a key that was enabled | Mark the key inactive in your registry |
key.rotated | POST /admin/keys/{id}/rotate succeeded | Invalidate any cached key material |
key.deleted | DELETE /admin/keys/{id} tombstoned the key | Drop the key from your registry |
Both budget events fire for keys and for budget groups; the payload’s
scope says which, and subject_id is the id you would pass to the
matching grant route.
Edge-triggering
A threshold fires once per budget epoch, not once per request past it. Cross 80% on Tuesday and every request for the rest of the week is silent. A grant that buys headroom re-arms the thresholds it drops below: top a $100 key that has spent $85 up to $300, and 85/300 = 28% re-arms 50% and 80% for the new epoch. A cap reduction does not un-fire what already fired.
budget.exhausted works the same way: the first refusal signals, the next
thousand do not, and a grant re-arms it.
One consequence worth planning for: a restart re-arms from the last
flushed spend, so an event can legitimately fire twice if the crash
window (auth.flush_interval_ms, 10 s by default) swallowed the settle
that first crossed it.
The payload
{
"id": "evt_9f2c1b7ad04e4a1c8f3b6e2d5a90c714",
"event": "budget.threshold",
"scope": "key",
"subject_id": "3b14b24efc6dc198000cdf5506ddea6d",
"subject_name": "team-search",
"budget_max": 100.0,
"budget_spent": 82.0,
"threshold": 80,
"ts": 1787691194
}
Accounting facts only. Never a plaintext key, never client metadata, never
prompt or response content - the same no-content construction as
usage_log. budget_max is omitted for an uncapped subject, and
threshold only appears on budget.threshold.
Verifying a delivery
Every POST carries four headers:
| Header | Meaning |
|---|---|
x-lumen-signature | Hex HMAC-SHA256 of the exact request body |
x-lumen-event-id | Unique per event, not per attempt |
x-lumen-event | The event kind, so you can route without parsing |
x-lumen-timestamp | The event’s own ts, which is inside the signed body |
Verify against the raw bytes you received, before any JSON round-trip:
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
The secret comes from whichever source is configured: the environment
variable named by signing_key_env wins when it is set and non-empty, and
the secret stored through PUT /admin/webhooks/signing-key fills in
otherwise. No route ever returns it, in any form; GET /admin/webhooks
reports the booleans signed and signing_key_stored and the variable
name, exactly as the provider surface reports key presence without key
material.
Delivery guarantees, and what they are not
At-least-once while the process lives. A retryable failure (5xx, 429,
408, or a network error) backs off exponentially with jitter up to
max_attempts, reusing the same x-lumen-event-id every time. Any other
non-2xx is treated as permanent and not retried - the receiver has said
this event will never be accepted. Nothing is persisted, so a restart
forgets undelivered events.
Receivers must be idempotent on x-lumen-event-id. Retries, and the
post-restart re-fire described above, both re-send the same id.
Not an accounting system. A full queue drops events rather than slow a
request down, and a receiver that stays down past the retry budget loses
them. GET /admin/usage/export remains the source of truth: reconcile
against it on a schedule and treat webhooks purely as a latency
optimisation over polling.
Never on the request path. Detection is a compare on the atomic budget
settle that already happens per request; delivery is a non-blocking
try_send into a bounded queue drained by a background task. A dead
receiver cannot add a millisecond to a customer’s request.
Watching it work
| Metric | Meaning |
|---|---|
lumen_webhook_queued_total | Events accepted into the queue |
lumen_webhook_sent_total | Events the receiver acknowledged with a 2xx |
lumen_webhook_dropped_total | Events dropped by a full queue - raise channel_capacity, or fix the receiver |
lumen_webhook_retries_total | Failed attempts that were retried |
lumen_webhook_dead_total | Events abandoned (retries exhausted, or a permanent rejection) |
lumen_webhook_delivery_seconds | Wall time of a single delivery attempt |
These series appear the first time a webhook is enabled, not at boot, so a
gateway that never uses them stays quiet on /metrics too.
Sustained dropped or dead means your billing loop is running blind:
fall back to the export route until the receiver is healthy again.
What a reload can change
A config reload re-resolves the same precedence: a stored row still wins,
so a reload never reverts a PUT. When the file block is what is in
force, every field is re-applied, and removing the block stops emission.
How a field is applied depends on the field. url, events, thresholds,
timeout_ms, max_attempts and retry_base_ms are retuned in place, on
the queue and sender task already running - so a retarget cannot lose what
is already queued. channel_capacity cannot be resized in place, so it
replaces both: new events go to the new queue while the previous sender
finishes delivering the events it had already accepted, then exits. Either
way nothing already accepted is discarded.
The one thing a reload cannot do is see a new environment variable: a
running process cannot observe a change to its own environment. Pointing
signing_key_env at a variable that was not set when the gateway started
needs a restart, or the sealed-secret route instead.
Operator notes
Per SECURITY.md,
protect LUMEN_MASTER_KEY and the SQLite database file together: either
one alone is not enough to read a stored provider key, but both together
decrypt it. Treat them as a single secret.
Lab integration: account refs and billing usage events
The Meilisearch Lab is a control plane that manages accounts, keys and one
credit ledger across several products. LUMEN integrates with it through two
contracts only, never through Lab internals: the Lab drives the admin API
to create and top up keys, and LUMEN pushes billing usage events back so
the Lab can charge for what was spent. Nothing here sits on the request path,
and a gateway without the [usage_events] block (and without the LAB_URL
and LAB_INSTANCE_ID variables) behaves exactly as before. The wire shapes
follow the Lab’s platform contract v2. The decision and its alternatives are
in ADR 015 and its 2026-10-09 amendment.
The lease model
- One budget group per Lab account per gateway. The group is the
account’s lease: its
budget_maxis the slice of the account’s shared credit wallet this gateway may spend. An account that uses several gateways has one group on each, and each gateway reports its own events. account_refon the group holds the Lab account id.external_refon a key holds the Lab key id. Both are opaque text, 1 to 128 characters when set. Set them onPOSTandPATCH;PATCHwithnullclears one. Both are returned by theGETroutes and filterable on the list routes:GET /admin/groups?account_ref=...andGET /admin/keys?external_ref=....account_refmust be a UUID while[usage_events]is configured, because the Lab rejects any other account id. A violation is400 LM-1001naming the field. Without the block, any 1 to 128 character value is accepted.- A key is billable when it has a group and that group has an
account_ref. Every other key is an operator key: never billed, behaving exactly as today.
The Lab’s loop, using routes that already exist:
POST /admin/groupswithaccount_refthe first time the account enables LUMEN on this gateway.POST /admin/keyswithgroup_idandexternal_refper customer key.- Keep the lease in step with the account’s credits through
GET /admin/groups/{id}andPATCH /admin/groups/{id}(orgrant), see “The lease sync” below. Admission is unchanged: an exhausted lease answers402 LM-4001, the correct answer for a customer who is out of credits. See Keys, quotas & budgets. - Scope every call it proxies for a user with
X-Lumen-Account-Ref, see “One gateway, many accounts” below.
Configuration
The Lab mints one id and one secret per reporting deployment (platform contract v2, section 3.3). Three environment variables, the same names Scrapix and glutony read, are all a gateway needs:
LAB_URL=https://lab.meilisearch.com # events go to {LAB_URL}/internal/events
LAB_INSTANCE_ID=<uuid> # this gateway's instance id
LAB_INSTANCE_SECRET=<64 hex> # shown once by the Lab; never logged
LAB_URL and LAB_INSTANCE_ID overlay the [usage_events] block (and
create it when the file has none); LAB_INSTANCE_SECRET is the default
secret_env. The block spells the same thing out in TOML:
[usage_events]
url = "https://lab.meilisearch.com" # LAB_URL
instance_id = "<uuid>" # LAB_INSTANCE_ID
secret_env = "LAB_INSTANCE_SECRET" # env var holding the instance secret
source = "eu-1" # optional label for the boot log
batch_size = 500 # 1..=500
timeout_ms = 5000 # 100..=60000
| Field | Required | Default | Bounds and meaning |
|---|---|---|---|
url | yes (or LAB_URL) | none | The Lab’s base URL; a trailing slash is ignored. https, or plain http only to localhost, a loopback address (IPv4 or IPv6) or a private IPv4 address. No user or password, no query, no fragment. LAB_URL wins over the file. |
instance_id | yes (or LAB_INSTANCE_ID) | none | The instance UUID the Lab minted. LAB_INSTANCE_ID wins over the file. A value that is not a UUID refuses to boot naming the field. |
secret_env | no | LAB_INSTANCE_SECRET | Name of the environment variable holding the instance secret, never the secret itself. Non-blank, no surrounding whitespace. |
signing_key_env | no | none | Deprecated alias of secret_env (the ADR 015 name). When set it wins, and boot logs one warning naming secret_env as the replacement. |
source | no | the instance id | Label in the boot log, 1 to 64 characters of A-Z a-z 0-9 . _ -. Not in events: the Lab knows the instance from X-Lab-Instance-Id. |
batch_size | no | 500 | Events per delivery request, 1 to 500: the Lab answers a larger batch with a 400 for the whole batch, which would never be acknowledged, so a larger value refuses to boot. |
timeout_ms | no | 5000 | Per-request timeout, 100 to 60000 ms. Also bounds the boot-time identity call. |
- Opt-in and restart-only. The block is part of the boot layer (Config source modes): a reload that changes it is refused, like any boot-layer key.
- Requires
[auth] enabled = true. Otherwise the gateway refuses to boot ([usage_events] requires auth.enabled = true). This includes a block created by the environment alone: a gateway with auth off that inheritsLAB_URLfrom an env file shared with Scrapix or glutony refuses to boot, so keep those variables out of a standalone gateway’s environment. - Both halves are needed.
LAB_INSTANCE_IDwithout any url refuses to boot with an error namingLAB_URL, and a url without an instance id refuses to boot namingLAB_INSTANCE_ID. - The secret must be set. The gateway refuses to boot when the variable
named by
secret_env(orsigning_key_env) is unset, empty or only whitespace. The error names only the variable. The secret is never logged, never in an error and never inDebugoutput. - Every live group’s
account_refmust already be a UUID. The admin API only enforces the UUID shape while the block is on, so a group given some other ref before billing was enabled would produce events the Lab rejects. With the block configured, the gateway checks every live group at boot and refuses to start if one has a non-UUIDaccount_ref; the error lists the offending group ids and nothing else. To fix it: boot without[usage_events](and withoutLAB_URL/LAB_INSTANCE_ID),PATCH /admin/groups/{id}each listed group with its Lab account UUID (ornullif it is not a Lab account), then restore the block and restart. - With no block (and neither
LAB_URLnorLAB_INSTANCE_IDset) there are no outbox rows, no sender task, nolumen_usage_events_*metric and no call to the Lab. - Removing the block strands pending events. Rows still in the outbox
stay there unsent while the block is absent (they go out once it is back).
Drain first: wait for
lumen_usage_events_pendingto reach 0 (stop billable traffic if new events keep arriving), stop the gateway, confirmSELECT COUNT(*) FROM usage_outbox WHERE delivered_ms IS NULL AND dropped_ms IS NULLis 0 (the shutdown flush may have written a last event), then remove the block.
Instance identity
At boot, with the block configured and before the sender starts, the
gateway calls GET {LAB_URL}/internal/instances/me once, with
Authorization: Bearer <instance secret> and X-Lab-Instance-Id (contract
v2, section 3.6), and logs the answer (instance_id, region, and the
source label). Engines are hosted by Meilisearch only (decision A of the
contract): the instance kind is hosted, and every event the gateway
reports is billed by the Lab.
- Boot is refused when the Lab answers
401or403(the credentials are wrong: checkLAB_INSTANCE_IDand the secret), or answers2xxwith a body that is not this gateway’s identity: a malformed body, aninstance_idother than the configured one (compared case-insensitively), aproductother thanlumen(the Lab would skip every event withproduct mismatch) or akindother thanhosted. - Anything else only warns (any other status, for example a
404from a Lab that does not serve the endpoint yet or a5xx, a connect error, a timeout, or a2xxwhose body stalls or breaks mid-read): the gateway logs one warning and boots. Events are durable and the sender retries them. - An unreachable Lab delays serving. The call is bounded by
timeout_ms(default 5000 ms) and runs after the listener is bound but before the gateway starts serving: while a Lab does not answer, the socket accepts TCP connections but HTTP requests,/healthincluded, wait up to that long. HTTP readiness probes need that margin; TCP probes pass at once. - Billing attribution is unchanged from ADR 015: a key is billed when its
group has an
account_ref; every other key is an operator key.
One gateway, many accounts
A hosted gateway serves many Lab accounts through one master key, so the
gateway enforces the account boundary itself (contract v2, section 8.3).
The Lab sets X-Lumen-Account-Ref: <account uuid> on every /admin/* call
it proxies for a user. The UUID is matched in its canonical lowercase form
whatever case the caller sends: every write stores a UUID account_ref in
lowercase, scoped or not (migration 0014 lowercased the ones stored before),
while a ref that is not a UUID is kept exactly as written. With that header:
| Routes | Behaviour |
|---|---|
GET /admin/keys, GET /admin/groups, GET /admin/usage, GET /admin/usage/export | Only rows of groups whose account_ref equals the header. A key with no group is never listed. A query filter naming another account (?account_ref= on groups, ?key_id= or ?group_id= on usage) matches nothing: a filter narrows the scope, never widens it. |
PATCH, DELETE, rotate and grant on a key; GET, PATCH, DELETE and grant on a group: when the key or group belongs to another account, or the key has no group | 404 LM-1003, the same envelope an unknown id gets, so a scoped caller cannot tell a foreign id from one that never existed. |
POST /admin/keys | group_id is required and must name a group of the account (400 LM-1001 without one, 404 LM-1003 for another account’s group). A scoped PATCH cannot clear group_id (400 LM-1001) or move the key to another account’s group (404 LM-1003). |
POST /admin/groups | account_ref is set to the header’s value when absent; a different value (compared ignoring case) is 400 LM-1001. A scoped PATCH that carries account_ref at all is 400 LM-1001. |
/admin/provider-keys/*, /admin/providers/{name}/check, /admin/webhooks*, /admin/config*, /openapi.json, /health/providers, /metrics | 403 LM-4005: platform-only, whatever the header’s value. /health/providers and /metrics need no key without the header, but they describe the whole platform (provider names and health, metric labels across every account), so a scoped call never sees them. /health stays open. |
- Webhooks are platform-only rather than filtered. The budget webhook receiver is one gateway-wide setting that carries every account’s budget events, so no account may read or change it. A per-account receiver is a possible later change.
- On the account routes, a header that is not exactly one UUID (malformed,
or sent twice) is
400 LM-1001, never read as “unscoped”. The master key is checked before the scope, so a missing or wrong key is still401 LM-4004. - Case does not matter for UUIDs. The header and every stored UUID
account_refare lowercase, so an uppercase header, an uppercase ref sent by an unscoped call or an uppercase?account_ref=filter all match the same groups. - Usage follows the group’s current account. The usage routes filter on
the
account_refa group has now: if an operator moves a group to another account, its usage history moves with it. - Without the header the master key keeps its unscoped, platform-operator behaviour, so an operator’s scripts and the Lab’s own provisioning calls are unchanged. The scope check and the write are separate store calls; only an unscoped master-key call running at the same moment can race them.
The lease sync
The Lab keeps each account’s lease in step with its credits (contract v2, section 8.2) through three routes, all accepting the scoping header:
| Route | What the Lab does with it |
|---|---|
GET /admin/groups/{id} | Reads the stored group record plus the live spent_micro (in-flight reservations included, like admission) and budget_max_micro (null when capless). |
PATCH /admin/groups/{id} with budget_max | Sets the cap absolutely (USD), so that budget_max_micro - spent_micro equals the account’s remaining credits in micro-USD. |
POST /admin/groups/{id}/grant with amount | Raises the cap by amount USD atomically (two concurrent top-ups both land). |
An account at zero credits has a zero remaining lease and the gateway
refuses its keys itself (402 LM-4001).
The billing rule
Billing rides on budget settlement, so it is exactly what the budget enforced.
- Settled cost, not spend. Each key carries a settled-cost counter, raised
in
settleby the real cost of a finished request. Reservation estimates held by in-flight requests are never billed. - Watermark. Each key also carries a billing watermark: the part of its
settled cost already turned into events, persisted in
virtual_keys.billed_micro. At every budget flush, a billable key’s event is the settled cost since the watermark. The delta is never negative, and a flush with nothing new settled emits nothing. - Atomic with the spend. The event row is written in the same SQLite transaction that persists the key’s spend and moves the watermark. After a failed commit nothing changes in memory and the next flush retries with a larger delta.
- Non-billable keys never bill their past. A key with no group, or in a
group without
account_ref, has its watermark kept equal to its settled cost. If it later joins a Lab-linked group, or its group gains anaccount_ref, only spend after that point is billed. Migration 0011 back-fills every existing key’s watermark from its current spend, so enabling[usage_events]on a running gateway bills only new spend. - Money is integer micro-USD. Events carry
provider_cost_micro_usd; the bill is never computed from the floating-pointbudget_spentcolumn. The Lab prices it, with the unit counts, into credits. - One flush, one lock. The periodic flush, the shutdown flush and the final flush of a deleted key share one function and one async lock, because two overlapping flushes would bill the same delta twice.
- Deleted keys are retired, not dropped. A deleted key stays in every flush until a flush that includes it has committed and no in-flight request still holds it. Its last spend, even from a request that is still streaming, is billed, and a failed final flush is retried.
- Crash window: one flush. A crash loses at most one flush interval of
settled cost, from the bill and from the persisted budget alike
(ADR 009 section 4). The interval is
[auth] flush_interval_ms(default 10000 ms).
Changing billability flushes first, and fails closed
With [usage_events] configured, these two admin calls first flush pending
spend, so it is billed to the account in force when it was spent:
PATCH /admin/keys/{id}withgroup_idin the body (includingnull);PATCH /admin/groups/{id}withaccount_refin the body (includingnull).
If that flush fails, the call answers 500 LM-5001 and changes nothing.
Retry it. The consequence for operators: while billing is on, a database or
outbox outage blocks key moves and account_ref changes. Serving requests
and top-ups are unaffected.
Residual: spend from a request admitted before the change but settled after
it follows the new membership. Clear a group’s account_ref only after the
account is closed.
A PATCH /admin/keys/{id} that changes only external_ref does not flush
first: it cannot change billability or the account. Spend settled before the
change but not yet flushed is reported under the new api_key_id, on the
same account, with no effect on the money billed.
The event
One usage.recorded event per billable key per flush that has new settled
cost, in the Lab-owned v2 shape (contracts/vendor/lab/lab-events.schema.json):
{
"id": "0192f3c1-7c2e-7b1a-9f00-3c9d2e4a5b61",
"type": "usage.recorded",
"occurred_at": "2026-09-30T15:04:05.123Z",
"account_id": "<group.account_ref>",
"api_key_id": "<key.external_ref or null>",
"product": "lumen",
"data": {
"operation": "gateway",
"units": { "requests": 412, "tokens_in": 90120, "tokens_out": 6000, "tokens_estimated": 310 },
"provider_cost_micro_usd": 1834,
"description": "key <lumen key id> 2026-09-30T15:03:55.120Z..2026-09-30T15:04:05.123Z"
}
}
idis a UUIDv7 minted once, when the outbox row is built just before the flush transaction writes it, so every retry carries the same id and the Lab deduplicates on it.operationis alwaysgateway: one event aggregates every capability of the key over the window, because LUMEN keeps one watermark per key. (The Lab schema’s enum forlumenalso listschat,embed,rerankandsystemone; that list predates thedecisionsrename of ADR 017, and LUMEN sendsgatewayonly.)unitsare raw counts the Lab prices: settled requests, input and output tokens, andtokens_estimated, the tokens (in plus out) of the requests whose counts were local estimates (ADR 003). A crash can lose units; money is only ever computed from the watermark.provider_cost_micro_usdis the settled cost since the watermark, in integer micro-USD, passed through and marked up by the Lab. Never the floating-pointbudget_spentcolumn, never a reservation.descriptioniskey <lumen key id> <window start>..<window end>.occurred_atand the window end are the flush time; the window start is the previous flush that billed this key (for its first event, the moment the key was loaded into memory at boot or created).- The instance is not in the body: the Lab takes it from the authenticated
X-Lab-Instance-Idheader. The lease snapshot (group),source,key_idandwindowthat ADR 015 carried indataare gone. - The schema’s
usageDataforbids additional properties, so nothing else is sent. The schema is the Lab’s, vendored byte for byte and never edited here; the serializer is tested against the vendored copy. The CI joblab-contract-driftcompares it withmeilisearch/labmain and fails on drift when theLAB_REPO_TOKENsecret is set; without the secret it skips with a notice, and while the Lab has not published its copy yet (404) it passes with a warning.
Delivery
One sender task per process, only when [usage_events] is set.
- Every 2 seconds (and immediately again while a full batch was just
acknowledged), it selects up to
batch_sizepending rows whose retry time has come, oldest first, and sendsPOST {url}/internal/eventswith body{"events":[...]}. - Headers:
Content-Type: application/json,X-Lab-Instance-Id: <LAB_INSTANCE_ID>,X-Lab-Timestamp: <unix seconds>andX-Lab-Signature: sha256=<hex>, where<hex>is the HMAC-SHA256, under the instance secret, of<X-Lab-Timestamp>.<raw body>. The timestamp is taken fresh for every attempt. The Lab rejects a timestamp more than 300 s from its clock: keep the gateway’s clock synced. - A
2xxresponse carries{"accepted":["<id>", ...]}. Only ids listed inacceptedare marked delivered. Ids not listed stay pending (see the drop rule below). - Anything else (connect error, timeout,
401, any other non-2xx status, a body that is not the expected JSON) leaves every row of the batch pending. Each failed row waitsmin(2^attempts, 300)seconds before its next try, starting at 2 seconds and doubling, capped at 5 minutes, plus up to 1 second of jitter. - A
401is logged as an error (checkLAB_INSTANCE_IDand the secret), counted, and retried forever. A wrong secret never takes the gateway down. - Rows are dropped in exactly one case. An event a reachable Lab keeps
out of
acceptedis permanently rejected after 24 h (contract v2, section 3.5). The clock starts at the first2xxanswer that leaves the event out (usage_outbox.first_skipped_ms, set once and never reset); the next such answer at least 24 h later drops it: the sender setsusage_outbox.dropped_ms, logs an error naming the event id and incrementslumen_usage_events_dropped_total. The body stays in the table until the 7-day purge, so the amount can still be reconciled by hand. A Lab that is unreachable, times out or answers a non-2xx status never starts or resets the clock and never drops anything, however long it lasts: those rows wait. This deliberately differs from webhooks, which are signals: these events are the bill. Delivered and dropped rows are purged after 7 days, checked hourly. - Redirects are not followed: the signed body reaches the configured
endpoint only. A 3xx counts as a failed delivery with reason
status. - Shutdown: the final budget flush writes its events, then the sender gets one delivery attempt bounded to 5 seconds. Whatever is left is durable and goes out after the next boot.
- All SQLite access for delivery runs on the sender task, never on the request path.
Metrics and the alert
| Metric | Meaning |
|---|---|
lumen_usage_events_pending | Events in the outbox neither acknowledged nor dropped. |
lumen_usage_events_oldest_pending_seconds | Age of the oldest pending event (0 when none). |
lumen_usage_events_delivered_total | Events acknowledged by the control plane. |
lumen_usage_events_failed_total{reason} | Events whose delivery attempt failed, one increment per event. reason is connect, timeout, auth, status, malformed, not_accepted or store. |
lumen_usage_events_dropped_total | Events the Lab kept refusing for 24 h and the gateway dropped. |
not_accepted means the Lab answered 2xx but left that id out of
accepted, and counts only rows that will be retried (a dropped row counts
in lumen_usage_events_dropped_total alone); store means the outbox itself
could not be read or updated. A rising lumen_usage_events_dropped_total
means the Lab refuses these events for good (an account this instance may
not report for, an unknown account): read the error log for the ids.
The starter alert LumenUsageEventsStuck in
monitoring/prometheus/alerts.yml
fires when lumen_usage_events_oldest_pending_seconds > 900 for 5 minutes.
Serving is unaffected while it fires, but every bill is delayed. Look at
lumen_usage_events_failed_total by reason: auth is a secret mismatch,
connect and timeout are reachability, status is a Lab-side error, and
not_accepted is the Lab skipping events. A second starter alert,
LumenUsageEventsDropped, fires on
increase(lumen_usage_events_dropped_total[1h]) > 0: an event was dropped
under the 24 h rule and was never billed; follow the runbook below to
reconcile it.
Runbook: an event the Lab never accepts
If delivery works for new events but the alert stays on, one row is probably
being refused (typically not_accepted keeps rising by the same small
count), and it pins lumen_usage_events_oldest_pending_seconds. Left alone,
it is dropped 24 h after the first refusal (see the drop rule in
“Delivery”) and the alert clears; the steps below settle it sooner or
reconcile it afterwards. The outbox is the usage_outbox table of the auth
database ([auth] db_path). Inspect the oldest pending rows read-only:
sqlite3 -readonly lumen.db "SELECT id, attempts, created_ms, next_attempt_ms, first_skipped_ms, body \
FROM usage_outbox WHERE delivered_ms IS NULL AND dropped_ms IS NULL ORDER BY created_ms, id LIMIT 5"
A non-null first_skipped_ms is when the Lab first answered without the id
(unix ms): the row is dropped at the first refusal 24 h after it. List what
was already dropped (and is kept until the 7-day purge) with:
sqlite3 -readonly lumen.db "SELECT id, first_skipped_ms, dropped_ms, body \
FROM usage_outbox WHERE dropped_ms IS NOT NULL"
Look at body (the exact event sent) and at the Lab’s logs for that id to
learn why it is refused. Then either:
- fix the cause on the Lab side (an account it does not know yet, an instance not allowed to report for it) within the 24 h: the row is retried unchanged and clears on its own; or
- settle it by hand when the Lab must never take it, for example because the body itself is wrong (a row’s body never changes once written): reconcile the amount with the Lab first (charge or waive it there), then mark that one row delivered, so it is purged after 7 days like any other:
sqlite3 lumen.db "UPDATE usage_outbox SET delivered_ms = CAST(strftime('%s','now') AS INTEGER) * 1000 \
WHERE id = '<event id>' AND delivered_ms IS NULL AND dropped_ms IS NULL"
A dropped row was never billed: reconcile its amount with the Lab by hand before the 7-day purge removes it. Never delete a pending row: it is the only record of that bill.
Upgrading a build from main with pending v1 events
No tagged release shipped the first usage-event shape (v1). Builds from
main since the first ADR 015 usage-events commit, after 0.6.1, may still
hold pending outbox rows in that shape, and a v2 Lab cannot validate them.
A v1 body has no data.operation. Those builds also predate the
dropped_ms column (migration 0013 adds it when the new build boots), so
set the rows aside in two steps.
-
Stop the old gateway, then inspect the v1 rows still pending and keep the output, it is the only record of that spend:
sqlite3 -readonly lumen.db "SELECT id, attempts, created_ms, body FROM usage_outbox \ WHERE delivered_ms IS NULL AND json_extract(body, '$.data.operation') IS NULL \ ORDER BY created_ms, id" -
Still before upgrading, park them so the new sender never selects them:
sqlite3 lumen.db "UPDATE usage_outbox SET next_attempt_ms = 9223372036854775807 \ WHERE delivered_ms IS NULL AND json_extract(body, '$.data.operation') IS NULL" -
Upgrade and start the new build, then mark the parked rows dropped (now in milliseconds), so they leave
lumen_usage_events_pendingand are purged after 7 days like any dropped row:sqlite3 lumen.db "UPDATE usage_outbox SET dropped_ms = CAST(strftime('%s','now') AS INTEGER) * 1000 \ WHERE delivered_ms IS NULL AND dropped_ms IS NULL AND json_extract(body, '$.data.operation') IS NULL"
The gateway never sends these rows, so the spend they carry
(data.cost_micro_usd in each body) must be reconciled with the Lab by
hand.
Sovereignty
Enabling this block makes the gateway call the configured URL. Events carry
no prompt, no response, no request metadata and no model id: only the account
and key refs, the LUMEN key id, unit counts, micro-USD cost and the window.
The boot-time identity call sends the instance credentials (the id and the
bearer secret) and nothing else. Per-model detail stays in usage_log and
GET /admin/usage/export, which remain best-effort and are not the billing
source. Without the block, this block adds no outbound call (webhooks, if
configured, are separate).
What the Lab must provide
LUMEN carries no Lab code. The Lab side of platform contract v2 is:
- Mint the instance id and secret (the hosted-engine rake task,
bin/rails lab:hosted_engine:create PRODUCT=lumen ...) and serveGET /internal/instances/me. - Accept
product: "lumen"withoperation: "gateway"and the four unit names in its owncontracts/lab-events.schema.json(the file LUMEN vendors), and price them insaas/config/pricing.yml(LUMEN’s money isprovider_cost_micro_usd). - Verify
X-Lab-Instance-Id,X-Lab-TimestampandX-Lab-SignatureonPOST /internal/eventsand answer{"accepted": [...]}. - Validate each event of a batch on its own and leave an invalid one’s id
out of
accepted; never answer non-2xx for a whole batch because of one event. A non-2xx never starts the gateway’s 24 h drop clock, so one bad event would stall billing for every event behind it. - Set
X-Lumen-Account-Ref(the canonical lowercase account UUID) on every proxied/admin/*call and never expose the platform-only routes to users. - Run
SyncLumenLeasesJobagainstGET /admin/groups/{id}andPATCH /admin/groups/{id}(orgrant), see “The lease sync”.
Config source modes: file and DB (ADR 012)
The dynamic half of LUMEN’s configuration - providers, routing, resilience,
pricing, tokenizer, image fetch, the two hot-reloadable [auth] knobs, and
[webhooks] - lives in exactly one place at a time: either the
boot TOML file (today’s default) or a table in the auth SQLite database. The
config_source boot key picks which. Both modes expose the identical admin
API, so a client cannot tell which one it is talking to.
The two modes
config_source = "file" # default
# config_source = "db"
"file" (default). The whole config - boot layer and dynamic layer
together - is the one TOML file the process was started with. PUT /admin/config and every granular write stage a new copy, back up the
previous one to <file>.bak, and rename atomically; an external edit (a
human, GitOps) is picked up by the file watcher on the next reload. This is
unchanged from before ADR 012 - every existing deployment needs no change.
"db". The dynamic layer lives in the config_versions table of the
same SQLite database [auth] already uses. Requires auth.enabled = true;
LUMEN refuses to boot otherwise (the admin plane is the only writer, and the
database is the store). The boot TOML file may then contain only
boot-layer keys - see below - since the DB is the dynamic layer’s sole
source of truth and a second copy in the file would be exactly the kind of
drift ADR 012 exists to prevent.
Boot layer vs. dynamic layer
A handful of settings are read once, at process start, and can never change
without a restart - the boot layer. Everything else is the dynamic
layer, owned by whichever ConfigSource is selected and editable through
the admin API without a restart.
| Boot layer (restart-only) | Dynamic layer (hot-reloadable, admin-API editable) |
|---|---|
[server]: host, port, body_limit, first_token_timeout_ms, sse_heartbeat_ms | [[providers]] (routing, foundation models, pricing) |
log_format | [[virtual_models]] (fallback, split, switch, presets, Jev remaps; see Virtual models) |
[telemetry] (the label allowlist becomes the Prometheus label set, fixed at startup) | [resilience] |
auth.enabled, auth.db_path | [tokenizer] |
auth.usage_channel_capacity, auth.usage_batch_max, auth.usage_flush_ms (the usage-log channel is sized at startup) | [image_fetch] |
[usage_events] (billing usage events, ADR 015; the outbox sender is wired at startup) | [webhooks] |
config_source itself | auth.flush_interval_ms, auth.retention_days |
Every dynamic-layer setting takes effect on the next hot reload, which an admin write triggers itself - so everything the admin API accepts is live as soon as the call returns.
In file mode this split is invisible day to day: the one file holds both
layers, exactly as before ADR 012. In db mode the boot file may hold
only the left-hand column’s keys: [server], log_format,
[telemetry], the boot-layer [auth] keys, [usage_events], and the
top-level config_source key itself. Any dynamic-layer key in that file - a
[[providers]] block, a [resilience] table, auth.flush_interval_ms or
auth.retention_days - is a
boot error naming the offending key, never a silently-ignored second
source. The error message tells you the fix: remove the key from the boot
file, or set config_source = "file".
The same split is enforced in the other direction on every admin write, and
just as strictly: in db mode, PUT /admin/config and every granular write
refuse a candidate that carries any boot-layer key at all -
server.*, log_format, telemetry.*, usage_events, config_source,
or a boot-layer [auth] key -
independent of what value it names, even one identical to the field’s own
built-in default. A dynamic document may never carry a boot-layer key; the
only way to change one is to edit the boot file and restart. See
If-Match and the boot-layer guard
below for why an equal-to-default value cannot be waved through.
The read side enforces the same rule: in db mode, every load of the stored
document (at boot, and on every reload) refuses one that carries a
boot-layer key, whatever its origin - a hand edit of config_versions or a
restored backup included. Boot fails naming the key; a reload keeps the
running config and logs the rejection. The gateway can therefore never
come up with auth silently disabled, pointed at another database file, or
bound to a different port than the boot file says.
First boot in DB mode
A fresh config_source = "db" deployment with nothing ever written to
config_versions boots into an empty-but-valid dynamic config: no
providers, every dynamic section at its built-in default. GET /health is
200, /v1/* answers the normal LM-2001 (no such model) rather than
refusing to start, and the log carries:
config_source = "db" and no config stored yet; PUT /admin/config to install one
This is what makes a fleet bootstrap a pure API flow: boot the process with
just [server]/[auth]/config_source = "db", then PUT /admin/config
with the real dynamic document once the process is reachable.
The admin API surface
Every route below is mounted only when [auth].enabled = true and gated by
the master key, exactly like the rest of /admin/*. All of them work
identically in both modes, and every mutating one requires If-Match.
| Method | Path | Purpose |
|---|---|---|
GET | /admin/config | The current dynamic document, verbatim, plus its content hash. File mode: the whole config file, byte for byte. DB mode: the stored document, or an empty string with the empty document’s hash before the first PUT. |
PUT | /admin/config | Replace the whole dynamic document. |
GET | /admin/config/providers | List every provider in the current document by name and kind, plus the document hash. |
GET | /admin/config/providers/{name} | One provider’s full config (its own fields, flattened) plus the document hash. |
PUT | /admin/config/providers/{name} | Create or replace that provider (including its models). The path {name} must equal the body’s own name field. |
DELETE | /admin/config/providers/{name} | Remove the provider. |
GET | /admin/config/virtual_models | List every virtual model (id, capability, strategy) plus the document hash. |
GET | /admin/config/virtual_models/{id} | One virtual model’s full config plus the document hash. {id} is percent-encoded when it contains / (acme%2Fchat). |
PUT | /admin/config/virtual_models/{id} | Create or replace that virtual model. The path {id} must equal the body’s own id field. |
DELETE | /admin/config/virtual_models/{id} | Remove the virtual model (refused while another virtual model targets it). |
GET | /admin/config/virtual_models/{id}/plan | The fully resolved routing tree, read-only. See Virtual models. |
GET | /admin/config/{section} | One of resilience, tokenizer, image_fetch, webhooks, auth (the dynamic sections; telemetry is boot-layer and has no granular route). Response: {"<section>": <value>, "hash": "<current hash>"}. |
PUT | /admin/config/{section} | Replace that section. auth is special - see The auth section below. webhooks also accepts null, which removes the [webhooks] block. |
Two admin surfaces are deliberately untouched by any of this:
PUT /admin/provider-keys/{name} (encrypted secrets, see
Keys, quotas & budgets)
and /admin/webhooks* (ADR 011’s DB-row-wins precedence over a [webhooks]
config block is unchanged, and applies to the document regardless of which
ConfigSource backs it). GET /admin/config/webhooks reads the [webhooks]
block from the document alone (null when absent) - a different thing
from GET /admin/webhooks, which reports whichever source is actually live.
If-Match and the boot-layer guard
Every mutating call - the whole-document PUT and every granular one -
requires an If-Match header carrying the hash from a prior GET:
curl -s http://localhost:8080/admin/config \
-H "Authorization: Bearer $LUMEN_MASTER_KEY"
# {"config": "...", "hash": "b1946ac9...", "key_sources": {"openai": "env"}}
curl -s -X PUT http://localhost:8080/admin/config \
-H "Authorization: Bearer $LUMEN_MASTER_KEY" \
-H 'If-Match: "b1946ac9..."' \
--data-binary @new-config.toml
- Missing
If-Matchis400LM-1001. - Stale
If-Match(the document changed since you read it - another operator’s apply landed first, or in file mode a human edited the file directly) is412LM-1004. Re-GETand re-apply. - A candidate that changes a boot-layer key (
server.*,log_format,telemetry.*,auth.enabled,auth.db_path, the usage-log channel knobs,usage_events,config_sourceitself) is400LM-1001, naming the changed key(s):"restart-only keys changed: server.port; edit the boot config file and restart". In file mode an unchanged boot-layer block still passes, since the candidate there is the whole file. - A db-mode candidate that carries any boot-layer key at all is
400LM-1001, unconditionally - naming the key:"unexpected key 'auth.enabled' in dynamic document: boot-layer keys are restart-only in config_source = \"db\" mode; set them in the boot config file instead". This is stricter than the diff above, and deliberately so: the stored document in db mode never carries boot keys, so every boot field on that side already resolves to its built-in default, and a candidate that sets one EXPLICITLY to that same default ([auth] enabled = false,[auth] db_path = "lumen.db",[server] port = 8080, …) would otherwise produce no diff against the current document and pass straight through. But a restart merges the stored dynamic document OVER the boot file (Config::load_with_dynamic), so that stored key would silently win at the next boot regardless of whether its value ever differed from the default - refusing to boot at all if it disables auth, pointing at the wrong database, or silently rebinding the listen port. A well-formed db-mode candidate simply carries no boot-layer keys at all; this check does not compare values, it refuses the key outright. - A candidate that fails validation (bad TOML, an unknown field, a
an invalid virtual model such as a cycle or a dangling target, …) is
400LM-1001, naming the field, the virtual model or the dependent model.
Every write runs under one process-wide lock, so two concurrent applies can
never interleave and defeat the If-Match check. The candidate is fully
re-validated (parse, semantic checks, a throwaway provider-registry build)
before anything is persisted - the same validation a restart would run - so
a granular edit can never leave behind a document a restart would refuse to
load. On success the hot-reload trigger fires (when one is armed), so the
change is live without a restart.
Granular edits preserve the rest of the document
PUT /admin/config/providers/{name} and PUT /admin/config/{section}
patch the current document with toml_edit rather than re-serializing it
from a parsed struct, so in file mode every comment and formatting choice
outside the table being touched survives untouched. In db mode the
document is machine-owned and this is harmless either way.
Deleting a provider, a foundation model or a virtual model that a virtual
model still targets is refused - 400 LM-1001, naming the dependent model - by the same
validation pass every other write goes through. Fetching an unknown
provider name, virtual model id or {section} is 404 LM-1003, the same style
every other per-entity admin lookup in LUMEN uses.
The auth section: two knobs only
GET/PUT /admin/config/auth covers exactly the two dynamic [auth]
knobs - flush_interval_ms and retention_days, which the background
tasks read live - never enabled, db_path or the usage-log channel knobs
(usage_channel_capacity, usage_batch_max, usage_flush_ms), which are
boot-layer. A PUT body naming any of those is rejected 400
(deny_unknown_fields on the request type), not silently ignored. Unlike
every other section, which the write replaces wholesale, auth merges
field-by-field into the existing [auth] table, so the boot-layer keys
survive untouched even though the request body never mentions them:
curl -s -X PUT http://localhost:8080/admin/config/auth \
-H "Authorization: Bearer $LUMEN_MASTER_KEY" \
-H 'If-Match: "b1946ac9..."' \
-H 'content-type: application/json' \
-d '{"flush_interval_ms": 5000, "retention_days": 30}'
DB-mode storage and retention
In db mode, every successful write inserts one immutable row into
config_versions (never updates one in place); the current document is
whichever row has the highest id. The newest 50 versions are retained -
older rows are pruned automatically on each write. There is no admin
endpoint to list or roll back to a previous version in this release (see
Backlog); the rows exist and are queryable directly
against the SQLite file if you need to recover one by hand.
Secrets never enter the document in either mode: api_key_env and
signing_key_env are environment-variable names, never values, so a
config_versions row is exactly as sensitive as the config file is
today - no more, no less.
Migrating between modes
Switching modes is an operational procedure, not an API call: it always
requires a restart, since config_source is itself boot-layer.
File to DB
-
GET /admin/configagainst the running file-mode process and save the response’sconfigfield (the response is a JSON{"config", "hash"}envelope; the TOML document is theconfigstring, not the raw body):curl -s http://localhost:8080/admin/config \ -H "Authorization: Bearer $LUMEN_MASTER_KEY" | jq -r .config > saved.tomlIn file mode this is the WHOLE config file, byte for byte (boot layer and dynamic layer together, see the admin API table above) - it still needs step 4 below before it is a valid db-mode candidate.
-
Edit the boot TOML: set
config_source = "db", and strip every dynamic-layer key ([[providers]],[[virtual_models]],[resilience],[tokenizer],[image_fetch],[webhooks], andauth.flush_interval_ms/auth.retention_days) - the boot file must contain only boot-layer keys once db mode is selected. Confirmauth.enabled = true. -
Restart. The process boots with an empty stored document (first boot in db mode, see above) -
/healthis healthy,/v1/*answersLM-2001until the next step. -
Before the
PUT, strip every boot-layer key from the document saved in step 1:[server],log_format,[telemetry],[usage_events],config_source, and the boot-layer[auth]keys (enabled,db_path,usage_channel_capacity,usage_batch_max,usage_flush_ms). Keepauth.flush_interval_msandauth.retention_daysif the file set them. The saved document is the file-modeGET’s WHOLE file, and the db-mode boot-layer guard (see If-Match and the boot-layer guard) now refuses a candidate carrying any boot-layer key unconditionally, even one whose value equals its built-in default -PUT-ing step 1’s document as-is is refused400LM-1001. -
PUT /admin/configwith the stripped document from step 4, using the empty document’s hash asIf-Match(theGET /admin/configa fresh db-mode boot returns before any write).
DB to file
GET /admin/configagainst the running db-mode process and save the response’sconfigfield (jq -r .config, as in the file-to-DB step 1) - the dynamic document alone, no boot keys.- Merge it with the boot-layer keys the process is currently running with
(
[server],log_format,[telemetry],[usage_events], and the boot-layer[auth]keys) into one TOML file. - Set
config_source = "file"(or remove the key -"file"is the default) in that same file. - Point
--config(or the existing config path) at the merged file and restart.
Either direction is a clean cutover: nothing reads from both sources at
once, and the old source (the boot file’s dynamic keys in the file→db
direction, or the config_versions table in the db→file direction) is
simply no longer consulted after the restart - it is not deleted, so
reverting is the same procedure run in the other direction.
See also
- ADR 012 - Config source abstraction for the design rationale.
- Keys, quotas & budgets for
[auth]itself, and for the encrypted provider-key and webhook surfaces this document deliberately does not cover. - Deployment - Hot reload for what a reload swaps and what stays restart-only outside the config-source boot key.
- Error codes for the full
LM-1001/LM-1003/LM-1004taxonomy.
Resilience tuning
LUMEN survives flaky upstreams without becoming flaky itself. A request goes
through, in order: retries, then fallback to the next target of a
virtual model, then the circuit breaker deciding
whether to even try a given provider. None of this touches the database on the
request path. Retries and the circuit breaker are always active with sane
defaults; fallback exists only where you declare a virtual model, and
background health checks are opt-in. All of it lives under
[resilience] - see ADR 005 for the
design.
Retries
Applied only to retryable upstream failures: 5xx, connect/read timeouts,
and 429. A client 4xx is never retried - a fallback provider would reject
it too. Backoff is exponential with equal jitter, and honors an upstream
Retry-After header as a floor (a Retry-After: 3 guarantees at least a
3-second wait). While streaming, commitment happens at the first content
frame, not at the open: after the upstream opens (2xx + headers), the
gateway peeks the first frame before committing. An upstream that opens
200 then errors or closes before delivering any content still retries and
falls over, charging the circuit breaker like an open failure, instead of
surfacing a terminal SSE error frame - so an immediately-dead stream is
still retried even though it opened. Once the first content frame reaches
the client the request is committed and a later mid-stream error becomes a
clean SSE error frame instead of a retry. See ADR 005 (first-frame-peek
amendment) and
Streaming.
[resilience]
retry_max_attempts = 3 # total attempts per provider incl. the first (>= 1)
retry_base_ms = 200 # base backoff wait after the first failure
retry_max_ms = 5000 # ceiling on the exponential backoff term
Set retry_max_attempts = 1 to disable retries entirely.
Fallback chains
Fallback is declared on a virtual model, not on the
foundation model. A fallback virtual model lists targets to try in order,
and fallback_on says which failures move on to the next one (default: a
5xx, a 429, a timeout or an open circuit; opt in to context_length and
content_filter). Targets can be other virtual models, so chains compose.
Chains are validated at boot and on every reload: every target must exist
and serve the same capability, with no cycles, so a runtime resolution miss
never happens in practice. Whichever foundation model actually served a
request is reported in the x-lumen-model-used response header, and the path
taken in x-lumen-route, so a caller (and the usage log) can see when a
fallback fired.
[[virtual_models]]
id = "gpt-4o"
capability = "chat"
strategy = "fallback"
fallback_on = ["provider_error", "rate_limited", "timeout", "circuit_open"]
targets = [{ model = "openai/gpt-4o" }, { model = "claude-sonnet-4-5" }]
Circuit breaker
Tracked per (provider, model) pair:
circuit_failure_threshold = 5 # consecutive failures that trip it open
circuit_cooldown_ms = 30000 # time spent open before a half-open probe
After circuit_failure_threshold consecutive provider-fault failures, the
circuit opens. While open, that link is skipped instantly - no upstream
call - straight to the next fallback, or answered with 503 LM-3020 (plus
Retry-After set to the cooldown remainder) if none remains. After
circuit_cooldown_ms, a single half-open probe decides whether to close
again. State is exported as lumen_circuit_state{provider,model} (0
closed, 1 open, 2 half-open).
The three timeouts
| Timeout | Code | Where configured |
|---|---|---|
| Connect | LM-3012 (504) | [resilience].connect_timeout_ms, shared by one pooled reqwest::Client. A provider may override it with its own connect_timeout_ms, at the cost of its own unpooled client (cross-provider connection pooling is lost for that provider only). A connect_timeout_ms of 0 is rejected at config validation. |
| First-token | LM-3011 (504) | [server].first_token_timeout_ms; per-provider overridable. Streaming: time to the first content frame (bounds the open-then-peek). Non-streaming: the whole call. |
| Total | LM-3013 (504) | [resilience].total_timeout_ms; per-provider overridable. Bounds the entire request - all retries and fallbacks together. |
A provider’s own connect_timeout_ms override is picked up on hot reload
like the other resilience knobs, since the registry rebuilds its clients
from the new config.
[resilience]
connect_timeout_ms = 5000
total_timeout_ms = 600000 # 10 minutes
Health checks
Off by default. When health_check_enabled = true, a background task
probes, on health_check_interval_ms, every provider that has an explicit
base_url (self-hosted TEI/Ollama, or any explicit override) - providers on
a built-in vendor URL are never probed and report unknown, since the
gateway hardcodes no vendor endpoints. Results are published at GET /health/providers and the lumen_provider_up{provider} gauge. The list
follows the live config: a provider added or removed by a hot reload
(including one requested by PUT/DELETE /admin/config/providers/{name})
appears as unknown or disappears once that reload has run, and, when
health checks were enabled at boot, a new provider with a base_url is
probed from the next interval on (health_check_enabled and
health_check_interval_ms themselves are read at boot: changing them takes
a restart). This is
independent of the gateway’s own liveness: GET /health never depends on
provider state and does no I/O.
These probes never send provider credentials, so a revoked key still reads
up. To check a key on demand, use
POST /admin/providers/{name}/check.
health_check_enabled = false
health_check_interval_ms = 30000
How this shapes the error codes you see
Retries, fallback and the circuit breaker all happen before a 3xxx error
ever reaches a client - by the time one surfaces, the resilience machinery
has already given up. See Error codes: “How resilience shapes these
codes” for the full mapping
from a given upstream failure to the code you’ll see.
Deployment
Docker
docker run -p 8080:8080 \
-v ./config.toml:/config.toml \
-e OPENAI_API_KEY=sk-... \
ghcr.io/qdequele/lumen:latest
The image is built from a multi-stage Dockerfile: a static musl binary
copied onto a distroless/static non-root base - no shell, no libc in the
final image, just the gateway. It is multi-arch (linux/amd64 and
linux/arm64). The image sets LUMEN_SERVER__HOST=0.0.0.0 for you, so the
server binds to all interfaces inside the container; mount your config at
/config.toml (the image’s default CMD).
Bare binary
Static musl binaries for x86_64-unknown-linux-musl and
aarch64-unknown-linux-musl are attached to every GitHub release cut from a
v* tag - a single self-contained file, no runtime dependencies, which
makes it systemd-friendly as a single process:
lumen --config /etc/lumen/config.toml
Bind the host/port via [server] in the config file, or the
LUMEN_SERVER__HOST / LUMEN_SERVER__PORT environment variables.
systemd unit
A minimal hardened unit. The two numbers that matter: TimeoutStopSec must
exceed the gateway’s 30 s drain window (see
Shutdown and restarts), and ReadWritePaths must
cover the auth database directory when auth is enabled (set an absolute
auth.db_path; the default lumen.db is relative to the working
directory).
[Unit]
Description=LUMEN gateway
After=network-online.target
Wants=network-online.target
[Service]
User=lumen
Group=lumen
WorkingDirectory=/var/lib/lumen
ExecStart=/usr/local/bin/lumen --config /etc/lumen/config.toml
# Provider API keys and LUMEN_MASTER_KEY, mode 0600, never in the config.
EnvironmentFile=/etc/lumen/env
# SIGHUP = config hot reload (see below). systemd's default stop signal is
# SIGTERM, which is the graceful-drain path.
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
# The gateway drains in-flight requests for up to 30 s on SIGTERM, then
# runs a final accounting flush with a bounded wait of up to 5 s; give the
# whole clean path headroom before systemd escalates to SIGKILL.
TimeoutStopSec=40
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/lumen
[Install]
WantedBy=multi-user.target
TLS and the reverse proxy
LUMEN intentionally does not terminate TLS. Put a reverse proxy (nginx, Caddy, your load balancer) in front of it, and leave HSTS to that proxy. The gateway speaks plain HTTP and should not be exposed directly to the internet without one.
Caddy needs two lines (automatic HTTPS, streams flush correctly by default):
gateway.example.com {
reverse_proxy 127.0.0.1:8080
}
nginx needs response buffering off, or SSE streams arrive in bursts instead of token by token, and a read timeout longer than your slowest stream:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 300s;
}
Every response does carry a conservative set of default security headers:
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: no-referrerContent-Security-Policy: default-src 'none'
Surface control
- Restrict
/admin/*and/metricsat the network layer (firewall, reverse proxy, service mesh) as appropriate for your deployment./admin/*requires the master key, but/metricsis unauthenticated by design - seeSECURITY.md. GET /healthis safe to point a liveness probe at: it never depends on provider state and does no I/O. It answers{"status": "ok", "version": "<gateway version>"}, unauthenticated, so a control plane can show which build it talks to.- Webhook receivers set through
PUT /admin/webhooksmust be public. Internal hosts (loopback, private, link-local,localhost, or names that resolve to them) are refused unless listed inLUMEN_WEBHOOK_ALLOWED_HOSTS(comma-separated hosts,.suffixfor a domain and its subdomains, read at boot). Set it when the receiver runs next to the gateway, for exampleLUMEN_WEBHOOK_ALLOWED_HOSTS=labin a Compose network. See Outbound webhooks.
Scaling and high availability
The honest answer to “can I run two replicas behind the load balancer?” is: it depends on whether auth is enabled, and v1 does not paper over that.
With [auth].enabled = false (the default), the gateway is a stateless
proxy: no database, no keys, no budgets. Run as many replicas as you like;
nothing breaks. Two per-instance caveats remain: circuit breakers and
health probes are per-process (each replica discovers a bad upstream on its
own), and /metrics is per-instance (scrape every replica and aggregate in
PromQL - the counters sum correctly).
With [auth].enabled = true, v1 is single-instance by design. Hard
budgets and RPM/TPM quotas are enforced in per-process memory (that is
what keeps the database off the request path), and spend is flushed to a
per-node SQLite file. Budget-group pools
(ADR 009) are enforced the same
way, in the same per-instance memory, so the single-instance constraint
applies to a shared pool identically. Behind a load balancer, N replicas
each enforce the full budget and quota independently: a $100 hard
budget becomes an effective $100 x N, an rpm_limit of 60 becomes
60 x N, and the usage ledger splits into N disjoint database files.
Nothing crashes - the guarantees silently stop meaning what they say,
which is worse.
Until then, the supported shapes with auth enabled are:
- One active instance. A supervisor (systemd, a single-replica
Kubernetes Deployment with
strategy: Recreate) restarts it; the drain semantics below bound the restart blip to seconds. - Active/passive: a standby instance behind a failover VIP or LB health check, sharing nothing. On failover the standby starts from its own (empty or restored) database; budgets re-enforce from the last flushed state of whatever database it opens.
A shared Postgres backend for the auth/usage store and distributed rate limiting are the v2 items that lift this constraint - see the backlog.
Hot reload
A SIGHUP, a file-watch event (file mode), an admin config write
(PUT /admin/config or a granular /admin/config/* write), or an admin
provider-key rotation (PUT /admin/provider-keys/{name}) triggers a config
reload: the new config is validated first, and only then are the provider
registry, price table, resilience policy, the runtime-safe [auth] knobs
(flush_interval_ms, retention_days), the webhook policy, the
image-fetch policy and the tokenizer atomically swapped in. Every reload
also re-reads DB-stored provider keys, so a key rotated via the admin API
takes effect without a restart even without an explicit trigger call; a DB
read error keeps the previous snapshot rather than stripping a working key.
In-flight requests are unaffected. If the new config is invalid, it is
rejected - the old config keeps serving, and
lumen_config_reload_failures_total increments so the failed reload is
visible in your dashboards.
The boot layer stays boot-time only and needs a real restart:
[server] (bind address, body limit, stream timeouts), log_format,
[telemetry] (its label allowlist becomes the Prometheus label set,
fixed when the metrics are registered), auth.enabled, auth.db_path,
the bounded usage-log channel knobs (usage_channel_capacity,
usage_batch_max, usage_flush_ms), usage_events (the billing
outbox sender is wired at startup; the LAB_URL, LAB_INSTANCE_ID and
LAB_INSTANCE_SECRET variables feed it, see
Lab integration) and config_source - rebinding a
live listener, re-registering metrics or resizing a running channel is out
of scope for a live swap. The admin config API refuses a change to any of
them (400 LM-1001) rather than accepting a value that would not apply;
edit the file and restart instead.
PUT /admin/config (ADR 010) applies a new config document remotely instead
of an operator editing the file by hand. In file mode (the default) it
stages the submitted bytes next
to the real config file, validates the staged copy, then backs up the
current file to .bak and renames the staged file into place before
triggering the same reload path as above. This means the gateway process
needs write permission on the config file’s directory, not just the file
itself (the staged file and the .bak sibling are both new files created
next to it, and the final apply is a rename within that directory). A
read-only config mount (a common hardening choice, e.g. a Kubernetes
ConfigMap volume or an immutable container layer) disables this route: the
staging write fails and the request is rejected with an internal error
(LM-5001), which is the correct refusal - the alternative would be a
silent apply that never actually took effect. GET/reading the config still
works read-only; only the PUT needs the extra permission. Relatedly, if
the config path is a symlink (a pattern some ConfigMap-mount setups and
manual atomic-deploy scripts use), an apply’s rename REPLACES the symlink
itself with a regular file - the same rename that lands the new document in
place cannot also preserve “the path is a symlink pointing elsewhere”; a
setup that depends on the config path staying a symlink across reloads is
not compatible with applying through this route. None of this applies in
db mode (Config source modes): the document lives in the
auth database, so the process needs no write access to the config file,
and a read-only config mount is fine. See
Applying a new config over the admin API
for the full request contract, and its security note on what holding the
master key implies once this route exists.
Shutdown and restarts
What each signal does:
| Signal | Effect |
|---|---|
SIGTERM / SIGINT | Graceful shutdown: stop accepting, drain in-flight requests (SSE streams included) for up to 30 seconds, then exit. |
SIGHUP | Config hot reload (above). Not a shutdown. |
The drain window is a built-in constant, not configurable, and a clean
stop can spend up to 5 more seconds on the final accounting flush below.
Tune your supervisor against the whole path: systemd TimeoutStopSec=40
(the unit above), Kubernetes terminationGracePeriodSeconds: 40. A
supervisor that kills sooner turns graceful restarts into the crash case
below; if draining ever exceeds 30 s, the gateway logs a warning and exits
anyway rather than hanging.
Accounting across a stop, when auth is enabled:
- Clean shutdown attempts a final accounting flush with a bounded wait. After the listener drains, the gateway performs a final budget flush and waits up to 5 seconds for the usage-log writer to drain its channel. Usage rows still in the channel when the wait expires are lost, as are rows from database write failures (both logged as warnings rather than blocking exit). In the common case, a clean shutdown loses no accounting.
- A crash loses at most
flush_interval_ms(default 10 s) of budget accounting and whatever usage-log rows were still in the bounded channel. Budget enforcement itself lives in memory ahead of the flush, so a running process never allows overruns. After a restart, however, budgets reload from the last persisted state: unflushed usage lost in a crash can permit spend beyond the intended budget until the gap closes.
Rolling restarts through the reverse proxy work as expected: mark the
instance down (or just send SIGTERM), let the 30 s drain finish the
in-flight streams, start the new binary. With auth enabled, avoid running
old and new concurrently for long - see
Scaling and high availability.
Backups
Everything durable lives in one SQLite file: auth.db_path (default
lumen.db). It is the only copy of the virtual-key hashes, the
encrypted provider keys stored via the admin API, the budget state, and
the entire usage_log ledger. With auth disabled there is no database and
nothing to back up.
-
Live backup (server running): the database runs in WAL mode, so use SQLite’s online backup rather than copying the file:
sqlite3 /var/lib/lumen/lumen.db ".backup '/backups/lumen-$(date +%F).db'"A live backup can trail reality by up to
flush_interval_ms(default 10 s) of budget accounting - the in-memory spend not yet flushed. -
Cold backup (consistent snapshot): stop the gateway first (a clean
SIGTERMattempts a final flush, see above), then copylumen.dbtogether with its-waland-shmsidecar files if present. The backup reflects the state at shutdown, though in-flight or unflushed usage rows from the bounded channel may not be included. -
Restore needs the matching
LUMEN_MASTER_KEY. Stored provider keys are encrypted under it; a restored database without the same master key serves virtual keys and history fine, but every stored provider key is undecryptable (re-enter them via the admin API). Per SECURITY.md, the key and the database are a pair: back up and protect them together, but never in the same place. -
Virtual keys are stored as BLAKE3 hashes and the plaintext is shown only once at creation: a lost database is unrecoverable key-wise. Clients keep their plaintext keys, but the gateway no longer knows them; they must be re-created. Back up on a schedule that matches how much
usage_loghistory you are willing to lose.
Validate configs in the pipeline
Run lumen --check-config in CI or your deploy pipeline before a real boot.
It performs the same parsing, semantic validation and provider registry
construction the server does at startup, then exits 0 if the config is
valid and non-zero otherwise - without binding a listener, opening a
database, or contacting a provider. See
Installation.
Upgrades
An upgrade is: replace the binary (or pull the new image), restart. This page covers the two things that make that boring in practice: the database migration story and what version numbers promise.
Before upgrading
- Read the target release’s section in
CHANGELOG.md. Release-specific upgrade notes live there (for example, 0.2.0 carries a one-line repair for databases created by 0.1.0, after a migration file’s checksum changed). - Back up the database if auth is enabled - see Backups. Upgrades run schema migrations automatically, and the backup is your downgrade path.
- Validate your config against the new binary before booting it for
real:
lumen-new --check-config --config /etc/lumen/config.toml. A removed or renamed config key fails here, in the pipeline, instead of at restart time.
Upgrading to virtual models (config change)
The release that introduces virtual models removes
per-model fallbacks, the [providers.models.rerank] block and the rerank
capability on typesafe models. Run the converter before rolling out:
lumen config migrate -c /etc/lumen/config.toml --dry-run # preview
lumen config migrate -c /etc/lumen/config.toml # rewrite, keeps a .bak
lumen --check-config --config /etc/lumen/config.toml
Clients keep sending the same ids, with one exception: a migrated model that
declared several capabilities keeps only the first one under its old id.
Calls to that id for the other capabilities return LM-2002 until you add a
virtual model for them (or point those clients at <provider>/<id>, which
serves every capability without fallback). The migration prints a note for
each such model, so read the --dry-run output and settle them before
rolling out. usage_log.model_used and the
Prometheus model labels of renamed foundation models change (for example
gpt-4o becomes openai/gpt-4o), so update dashboards and queries that
filter on them. Migration 0010 adds the nullable usage_log.route column.
See Migrating from fallbacks.
Schema migrations run themselves
When auth is enabled, the gateway applies its embedded, numbered SQLite
migrations automatically at boot (one per schema change, listed in
crates/auth/migrations/). There is
no separate migrate command and nothing to run by hand.
- Forward-only. There are no down-migrations. Rolling back to an older binary against a database a newer binary already migrated is not supported: the older binary refuses to start when it finds applied migrations it does not know. The downgrade path is the pre-upgrade backup.
- Integrity-checked. Each applied migration’s checksum is verified at boot; a mismatch is a hard, named error rather than a silent divergence.
- With
[auth].enabled = falsethere is no database and this whole section is moot.
The restart itself
SIGTERM the old process (or let your supervisor do it), start the new
one. In-flight requests get up to 30 seconds to finish and a clean
shutdown attempts a final accounting flush with a bounded wait (up to 5
seconds) - the mechanics and the supervisor timeouts to pair with them are
in Shutdown and restarts. With auth
enabled, prefer stop-then-start over running old and new side by side; two
live instances double-enforce budgets and quotas for as long as they
overlap (see
Scaling and high availability).
What version numbers promise
LUMEN is pre-1.0 and follows SemVer’s 0.x rules: a minor bump
(0.2 -> 0.3) may contain breaking changes; a patch bump does not. Every
breaking change is called out explicitly in the CHANGELOG. Per surface:
- HTTP API: the OpenAI-compatible (
/v1/chat/completions,/v1/embeddings) and Cohere-compatible (/v1/rerank) surfaces track those upstream formats; gateway-specific behavior changes are CHANGELOG items. LM-xxxxerror codes are stable identifiers. Codes are never renumbered or reused; new ones are added and documented in the error reference.- Config format: keys can be added in any release; removals or renames
are breaking changes (CHANGELOG + caught by
--check-config, which rejects unknown keys). - Metrics: renaming or re-labeling an exported series is a breaking change for your dashboards and is called out in the CHANGELOG.
Model release watcher
LUMEN connectors are per provider, not per model. A new model on a provider
LUMEN already supports only needs a [[providers.models]] entry; a new
provider, or a new API shape, needs code in crates/providers. The model
release watcher tells maintainers which of the two each new release is, so
neither goes unnoticed.
It is a scheduled GitHub Actions workflow
(.github/workflows/model-watch.yml)
running a standard-library Python script
(scripts/model-watch/model_watch.py).
It needs no secrets: every source is public, and it files issues with the
workflow’s own GITHUB_TOKEN. It is a repository maintenance tool; it never
runs inside the gateway.
What it does
Daily (06:17 UTC) and on manual dispatch, it:
- Fetches three public catalogs: models.dev,
LiteLLM’s
model_prices_and_context_window.json, and OpenRouter’s/api/v1/modelsplus/api/v1/embeddings/models. LiteLLM’sopenai_like/providers.jsonis read as a hint for which of its providers speak the OpenAI format. - Diffs them against the committed snapshot,
scripts/model-watch/snapshot.json: per source, provider id to model id to capability (chat,embeddingorrerank; image, audio and video models are ignored). - Classifies every addition against what LUMEN supports, read at run
time from
crates/providers/src/kind.rs(theProviderKindvariants,is_openai_compatible,default_base_url,supports_embeddings) and the capability tables indocs/providers.md:- (a) config only: a new model on a provider LUMEN supports. If the kind does not serve that capability yet (an embedding model on an embed-less kind, say), it is listed as a capability gap instead.
- (b) OpenAI-compatible provider: a provider LUMEN does not know whose
catalog entry declares an OpenAI-compatible base URL. Candidate for a new
ProviderKindvariant in the OpenAI-compatible family. - (c) needs a connector: a provider with a native API (or one the catalogs cannot vouch for), or any rerank model from an unsupported provider, since rerank has no OpenAI wire format.
- Files issues, labelled per CONTRIBUTING.md:
- one issue per (b)/(c) provider, titled
Provider candidate: <name> [model-watch:provider:<id>], with the catalog ids, base URL and a next-steps checklist. Labels:enhancement,area: providers,priority: mediumwhen it brings embedding or rerank models (otherwisepriority: low), and the matchingscope:labels. A later run comments on the open issue instead of opening another. - one rolling issue,
New models on supported providers [model-watch:supported-models], for every (a) addition, grouped by LUMEN kind. Each run adds a dated comment.
- one issue per (b)/(c) provider, titled
- Commits the refreshed snapshot to the branch it ran on, only after every issue was filed.
Providers that were already in the catalogs when the snapshot was first taken are the baseline: the watcher stays quiet about their new chat models (many are aggregators reselling the same models), and only speaks up when they add an embedding or rerank model.
Triage
- Close a provider issue once handled, or as
wontfixto silence it. The watcher never reopens a closed provider issue. - Close the rolling issue after the batch is triaged; the next run with new models opens a fresh one.
- Teach it a mapping: when a catalog spells a supported provider
differently (
together_aifortogether), add the id toscripts/model-watch/providers.json. Ids are normalized (lowercase, separators removed) and a provider whose base URL equals a kind’sdefault_base_urlis matched automatically. A capability only described in prose indocs/providers.md(Together and Cloudflare rerank) goes underextra_capabilitiesthere. - A new
ProviderKindis picked up by the next run with no watcher change, as long as it is also added to thedocs/providers.mdtables (the run summary warns about a kind missing from them).
Running it locally
python3 scripts/model-watch/model_watch.py --dry-run # what would be filed today
python3 scripts/model-watch/model_watch.py --dry-run --show-bodies # with full issue bodies
python3 -m unittest discover -s scripts/model-watch # unit tests
--dry-run never writes to GitHub or to the snapshot. With GITHUB_TOKEN
and GITHUB_REPOSITORY set, it also reads existing watcher issues, so it
reports comment and skip where a real run would. --source-dir DIR reads
<source>.json files from DIR instead of fetching (offline replay).
--init rebuilds the snapshot from today’s catalogs without filing anything,
for example after a deliberate catalog id migration.
Failure handling
- A catalog changes shape or is unreachable: that source is skipped, its
previous snapshot section is kept, the healthy sources are still processed,
and the run goes red. Adapt the matching
parse_*function. - A catalog shrinks below half its previous size, or below a sane minimum: treated the same way, so an outage page is never recorded as a mass removal (which would re-announce everything on recovery).
- Too many new issues in one run (more than
--max-new-issues, default 10; a dispatch input): nothing is filed and the snapshot is left as is. This is almost always a catalog renaming its ids. Inspect with--dry-run, then either add aliases or re-dispatch with a higher limit. - Issue filing fails midway: the snapshot is not written, so the next run re-detects the same additions (an open issue may receive the same comment twice).
- Schedule paused: GitHub disables scheduled workflows after 60 days without repository activity. Re-enable it from the Actions tab; the committed snapshot makes the first run after a pause catch up in one batch.
Why the snapshot is committed
The snapshot is the watcher’s only state, and it is committed back to main
rather than kept as a workflow artifact. Artifacts expire (90 days at most),
and a lost snapshot means either refiling the whole catalog or silently
skipping a window of releases. A committed file survives pauses, is
reviewable with git log -p, and lets anyone reproduce a run locally with
--dry-run. The cost is one small bot commit on days the catalogs change;
main only blocks deletions and force-pushes, so the workflow’s token can
push it, and commits made with that token do not trigger other workflows.
Future work
Not built yet: a later phase could hand type (c) issues to a Claude Code
GitHub Action running the provider-integrator agent to draft a connector
PR, always for human review. The watcher only detects and triages; it never
writes provider code.
Examples
Runnable scenario configs live in
examples/ at the
root of the repository. Each directory is self-contained: a config.toml
(the gateway config), a README.md (what it demonstrates and any
prerequisites), and a run.sh (the requests to fire once the gateway is
up).
Every scenario follows the same two-terminal recipe: start the gateway with
the scenario’s config in one terminal, then fire its run.sh in another.
Provider keys are never written into a config file; each config reads them
from the environment variable named by its api_key_env field.
Every config.toml in examples/ passes lumen --check-config in CI.
minimal-chat
The smallest possible LUMEN config: one provider (OpenAI), one model
(gpt-4o), chat only.
Demonstrates: non-streaming chat via POST /v1/chat/completions, and
the same endpoint with "stream": true.
Env vars: OPENAI_API_KEY.
# terminal 1
export OPENAI_API_KEY=sk-...
cargo run -p server -- --config examples/minimal-chat/config.toml
# terminal 2
./examples/minimal-chat/run.sh
examples/minimal-chat on GitHub
self-hosted
A fully keyless config: no cloud provider, no API key anywhere. Chat and embeddings come from Ollama, reranking from TEI. Everything runs offline once the models are pulled.
Demonstrates: chat against Ollama’s OpenAI-compatible endpoint, embeddings against Ollama’s native endpoint, and reranking against a local TEI server.
Env vars: none. Requires Ollama running locally with llama3.2 and
nomic-embed-text pulled, and (optionally) TEI serving
BAAI/bge-reranker-large on port 8081.
# terminal 1
cargo run -p server -- --config examples/self-hosted/config.toml
# terminal 2
./examples/self-hosted/run.sh
examples/self-hosted on GitHub
multi-provider-fallback
Cross-vendor chat fallback: a gpt-4o virtual model
serves from OpenAI (openai/gpt-4o) first, then from claude-sonnet-4-5
(Anthropic) when OpenAI fails.
Demonstrates: the x-lumen-model-used response header reporting
whether the primary or the fallback served a request, and how the circuit
breaker trips after repeated failures on the primary. See
Resilience tuning.
Env vars: OPENAI_API_KEY, ANTHROPIC_API_KEY (the Anthropic key is
only used if the fallback actually fires).
# terminal 1
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=sk-ant-...
cargo run -p server -- --config examples/multi-provider-fallback/config.toml
# terminal 2
./examples/multi-provider-fallback/run.sh
examples/multi-provider-fallback on GitHub
rag-pipeline
The two calls behind a typical RAG pipeline, wired to two different
providers: embeddings via OpenAI (text-embedding-3-small) at index time,
reranking via Cohere (rerank-english) at query time.
Demonstrates: POST /v1/embeddings embedding a small document corpus,
then POST /v1/rerank re-scoring the same documents against a query with
"top_n": 2. See Embeddings and
Reranking.
Env vars: OPENAI_API_KEY, COHERE_API_KEY.
# terminal 1
export OPENAI_API_KEY=sk-...
export COHERE_API_KEY=...
cargo run -p server -- --config examples/rag-pipeline/config.toml
# terminal 2
./examples/rag-pipeline/run.sh
examples/rag-pipeline on GitHub
multi-tenant-analytics
Per-tenant cost and usage attribution: [auth] enabled with a virtual key
per tenant carrying a hard budget, and [telemetry].metadata_labels
turning the x-lumen-metadata header into Prometheus labels.
Demonstrates: creating a virtual key through the admin API
(POST /admin/keys, master-key bearer), tagging requests with
x-lumen-metadata, and slicing lumen_tokens_total on /metrics by
org_id. See Usage log & multi-tenant metadata
and Keys, quotas & budgets.
Env vars: OPENAI_API_KEY, LUMEN_MASTER_KEY (64 hex characters,
e.g. openssl rand -hex 32).
# terminal 1
export OPENAI_API_KEY=sk-...
export LUMEN_MASTER_KEY=$(openssl rand -hex 32)
cargo run -p server -- --config examples/multi-tenant-analytics/config.toml
# terminal 2
export LUMEN_MASTER_KEY=... # same value as terminal 1
./examples/multi-tenant-analytics/run.sh
--check-config on this scenario does not need LUMEN_MASTER_KEY set: the
master key is a secret read from the environment at actual server startup,
never part of the config file (and the config loader explicitly ignores it).
examples/multi-tenant-analytics on GitHub
HTTP API reference
The gateway’s HTTP contract is one OpenAPI 3.1 document,
docs/openapi.yaml
(its info.version is the gateway version): the OpenAI-compatible /v1
surface (chat completions, embeddings, rerank, decisions, models), the
operational routes (/health, /health/providers, /metrics) and
every master-key /admin route a control plane drives.
- A test in
crates/server/src/app.rsfails the build when a mounted route is missing from the document or the document names a route that is not mounted, so the file always describes the running router. - With
[auth] enabled = truethe gateway serves the same document as JSON atGET /openapi.json(master key), which is how the Meilisearch Lab vendors it. Like the other platform routes it answers403 LM-4005to a call scoped withX-Lumen-Account-Ref(see Lab integration). - Every error is the
{"error": {"code": "LM-xxxx", ...}}envelope of Error codes.
Render it with any OpenAPI viewer, for example
npx @redocly/cli@1 build-docs docs/openapi.yaml (writes a static
redoc-static.html).
Providers
LUMEN ships twenty-seven built-in provider kinds - sixteen native integrations
(their own request/response translation, including deployment-routed azure,
SigV4-signed bedrock and decisions-only typesafe) plus eleven
OpenAI-compatible hosts that reuse the OpenAI path with a per-kind base
URL. Each [[providers]] block in your config selects one with a kind
string and gives it a unique name (your own label). Each [[providers.models]] block under it exposes a model to clients:
[[providers]]
name = "my-openai" # your label; must be unique
kind = "openai" # selects the built-in implementation
api_key_env = "OPENAI_API_KEY" # NAME of the env var holding the key
# base_url = "https://…" # optional override (required for self-hosted kinds)
[[providers.models]]
id = "gpt-4o" # the id clients send (owned entirely by you)
upstream_id = "gpt-4o-2024-08-06" # what LUMEN sends upstream (defaults to `id`)
capabilities = ["chat"] # any of "chat", "embed", "rerank", "decisions"
release_date = "2024-08-06" # optional; lets clients sort GET /v1/models by release
Rules that apply to every provider:
- API keys are never in the config.
api_key_envnames an environment variable; LUMEN reads it only when a request actually routes to that provider. A hosted provider whose env var is unset fails only at use, not at boot - a partial set of keys is fine. release_dateis metadata only. An ISO date ("2024-08-06", or the unquoted TOML date2024-08-06), validated at load.GET /v1/modelsreturns it asrelease_dateand as the OpenAI integercreated(Unix seconds, midnight UTC); both are omitted for an undated model. It never affects routing.- Model ids are globally unique across all providers. A collision aborts
startup and names both offending providers. Several ids may map to one
upstream_id(versioned aliasing). capabilitiesmust match the kind. A model can only declare capabilities its provider kind implements (table below); this is validated at boot.base_urlis an optional override for hosted kinds, and required for the self-hosted kinds (tei,ollama), which are keyless.- Batching: an embed request with more inputs than the provider’s batch limit is split into sub-batches, run with bounded concurrency, and reassembled in the original order. The limits below are built in.
- Multimodal embeddings (M9): declare
modalities = ["text", "image"]on a model to accept image content parts on/v1/embeddings.inputitems may be strings or arrays of parts ({"type":"text",...}/{"type":"image_url",...}). Images are passed asdata:URIs, or - with[image_fetch]enabled - as remotehttp(s)URLs the gateway fetches under SSRF/resource guards and inlines. Image input to a model without"image"is rejected withLM-2003; a remote URL with fetching disabled isLM-2005. Cohere (embed-v4) and Voyage embed a combined text+image vector per item; Jina embeds one modality per item (a mixed item is sent as its image, its caption text is not combined). See the multimodal-embeddings design spec for the full guard list.
kind | Chat | Embed | Rerank | Decisions | api_key_env | base_url | Embed batch limit |
|---|---|---|---|---|---|---|---|
openai | ✅ | ✅ | ✅ | required | optional | 2048 | |
mistral | ✅ | ✅ | required | optional | 512 | ||
anthropic | ✅ | required | optional | - | |||
google | ✅ | ✅ | required | optional | 100 | ||
vertex_ai | ✅ | ✅ | required (SA JSON) | required (GCP region) | 1 | ||
bedrock | ✅ | ✅ | AWS SigV4 | optional | 1 | ||
cohere | ✅ | ✅ | ✅ | required | optional | 96 | |
jina | ✅ | ✅ | required | optional | 2048 | ||
voyage | ✅ | ✅ | required | optional | 128 | ||
mixedbread | ✅ | required | optional | - | |||
pinecone | ✅ | required | optional | - | |||
nvidia | ✅ | keyless | required | - | |||
tei | ✅ | ✅ | keyless | required | 32 | ||
ollama | ✅ | ✅ | ✅ | keyless | required | 512 | |
azure | ✅ | ✅ | required | required | 2048 | ||
typesafe | ✅ | ✅ | required | optional | - |
The typesafe kind serves decisions only; reranking a decision model goes
through a virtual model remap (see
Decision models as rerankers).
perplexity and cloudflare (OpenAI-compatible table below) also serve
decisions. The full decisions matrix is below.
The together kind (in the OpenAI-compatible table below) additionally serves
rerank (LlamaRank) natively; see its section for the model config.
OpenAI-compatible hosts (chat + embed via the OpenAI path). The Embed column
reflects what each host actually serves upstream: groq, deepseek,
perplexity and xai expose no /embeddings endpoint (perplexity
and cloudflare do serve decisions, see Decisions by provider
kind), so a
model declaring embed on those kinds is rejected at config load (it
could only ever 404 at request time) - unless the provider sets a custom
base_url, which is taken to mean an operator-run proxy that may serve
embeddings:
kind | Chat | Embed | api_key_env | base_url | Default base URL |
|---|---|---|---|---|---|
groq | ✅ | no | required | optional | https://api.groq.com/openai/v1 |
together | ✅ | ✅ | required | optional | https://api.together.xyz/v1 |
fireworks | ✅ | ✅ | required | optional | https://api.fireworks.ai/inference/v1 |
deepseek | ✅ | no | required | optional | https://api.deepseek.com/v1 |
openrouter | ✅ | ✅ | required | optional | https://openrouter.ai/api/v1 |
perplexity | ✅ | no | required | optional | https://api.perplexity.ai |
xai | ✅ | no | required | optional | https://api.x.ai/v1 |
deepinfra | ✅ | ✅ | required | optional | https://api.deepinfra.com/v1/openai |
huggingface | ✅ | ✅ | required | optional | https://router.huggingface.co/v1 |
cloudflare | ✅ | ✅ | required | required | - (URL embeds your account id) |
vllm | ✅ | ✅ | keyless | required | - (your self-hosted server) |
Self-hosted or catalog-dependent kinds (vllm, huggingface, cloudflare)
stay permissive: the operator controls what their endpoint serves. If one of
the embed-less hosts above later ships an embeddings API, point a kind = "openai" provider at it with a base_url override, or file an issue to
update the capability table.
All embed-serving OpenAI-compatible kinds use a 2048-input embed batch
limit. Anything that speaks the OpenAI wire format but isn’t listed can
still be used via kind = "openai" with a base_url override.
cloudflare additionally serves rerank (not shown in the table above,
which covers only the chat/embed OpenAI-compatible path): its BAAI
bge-reranker-* models are served through Workers AI’s native
/ai/run/{model} endpoint rather than an OpenAI-compatible one. See
### cloudflare below.
openai
- kind:
openai· capabilities: chat, embed, decisions - Auth:
api_key_env(e.g.OPENAI_API_KEY), sent as a bearer token. - base_url: optional; defaults to OpenAI’s public API. Set it to point at any OpenAI-compatible endpoint.
- Embed batch limit: 2048 inputs per upstream call.
- Decisions:
gpt-6-lunaonPOST {base_url}/decisions(public beta). Achoiceneeds at least 2 options and a request at most 128 images. Setcost_per_1m_input = 0.10andmodalities = ["text", "image"]. See Decisions.
[[providers]]
name = "openai"
kind = "openai"
api_key_env = "OPENAI_API_KEY"
[[providers.models]]
id = "gpt-4o"
upstream_id = "gpt-4o-2024-08-06"
capabilities = ["chat"]
[[providers.models]]
id = "text-embedding-3-small"
capabilities = ["embed"]
[[providers.models]]
id = "gpt-6-luna"
capabilities = ["decisions"]
modalities = ["text", "image"]
cost_per_1m_input = 0.10
mistral
- kind:
mistral· capabilities: chat, embed (OpenAI-compatible). - Auth:
api_key_env(e.g.MISTRAL_API_KEY), bearer token. - base_url: optional override.
- Embed batch limit: 512.
[[providers]]
name = "mistral"
kind = "mistral"
api_key_env = "MISTRAL_API_KEY"
[[providers.models]]
id = "mistral-small"
upstream_id = "mistral-small-latest"
capabilities = ["chat"]
anthropic
- kind:
anthropic· capabilities: chat only. - Auth:
api_key_env(e.g.ANTHROPIC_API_KEY). LUMEN authenticates with thex-api-key/anthropic-versionheaders, not a bearer token. - Translation: OpenAI ⇄ Anthropic is bidirectional, including tools and
streaming events, so clients keep using the OpenAI wire format.
parallel_tool_calls: falsemaps totool_choice.disable_parallel_tool_use;response_format,seed,logprobs,frequency_penaltyandpresence_penaltyhave no Messages API equivalent - see chat extras and thestrictflag. - base_url: optional override.
[[providers]]
name = "anthropic"
kind = "anthropic"
api_key_env = "ANTHROPIC_API_KEY"
[[providers.models]]
id = "claude-sonnet-4-5"
upstream_id = "claude-sonnet-4-5-20250929"
capabilities = ["chat"]
- kind:
google· capabilities: chat, embed (Gemini). - Auth:
api_key_env(e.g.GEMINI_API_KEY). The key rides thex-goog-api-keyheader, never the URL. - Translation: OpenAI ⇄ Gemini, including streaming (
streamGenerateContent).response_formatJSON mode maps togenerationConfig.responseMimeType/responseSchema,seedtogenerationConfig.seed, andfrequency_penalty/presence_penaltytogenerationConfig.frequencyPenalty/presencePenalty;logprobsandparallel_tool_callshave no mapping - see chat extras. - Embeddings: served through
models/{model}:batchEmbedContents(gemini-embedding-001,text-embedding-004, …). One inner request per input; the OpenAIdimensionsfield maps tooutputDimensionality. The API is text-only: pre-tokenized token-id arrays and image content parts are rejected withLM-1001before any upstream call. Usage follows ADR 003:usageMetadata.promptTokenCountis reported when the upstream returns it, otherwise the gateway derives a local estimate markedestimated. - Embed batch limit: 100 inputs per upstream call (Gemini’s documented
batchEmbedContentsceiling); the gateway splits larger requests. - base_url: optional override.
[[providers]]
name = "google"
kind = "google"
api_key_env = "GEMINI_API_KEY"
[[providers.models]]
id = "gemini-2.0-flash"
upstream_id = "gemini-2.0-flash"
capabilities = ["chat"]
[[providers.models]]
id = "gemini-embedding-001"
capabilities = ["embed"]
vertex_ai
- kind:
vertex_ai· capabilities: chat, embed (Gemini andtext-embedding-*models on Google Cloud Vertex AI). Distinct fromgoogle, which is the public Gemini Developer API: Vertex uses regional endpoints and GCP OAuth instead of a static API key. - Auth:
api_key_envnames an env var holding the full service-account key JSON (the contents of the key file downloaded from GCP, not a path and not an API key). LUMEN signs an RS256 JWT assertion with the account’s private key, exchanges it at the account’stoken_urifor a short-lived OAuth2 access token (scopecloud-platform), and sends it as aBearerheader. Tokens are cached in memory and refreshed 60 s before expiry, so the exchange stays off the per-request hot path. The private key is redacted from allDebugoutput and never appears in logs or errors. - base_url: required - it carries the GCP region (e.g.
us-central1), not a URL. The endpoint is derived from it:https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/google/models/{model}:generateContent(and:streamGenerateContent?alt=ssewhen streaming). - Project id: taken from the service-account JSON’s
project_id. - Translation: chat is identical to
google(sameGenerateContentwire schema), including streaming. Like Gemini, only inline base64 image data is accepted; remote image URLs are rejected withLM-2004. - Embeddings: Vertex does NOT expose
batchEmbedContents; embeddings go through the prediction API on the same regional, project-scoped path:.../publishers/google/models/{model}:predictwithinstances[].content. The OpenAIdimensionsfield maps toparameters.outputDimensionality. Text-only: token-id arrays and image parts are rejected withLM-1001. Usage follows ADR 003: per-inputstatistics.token_countvalues are summed and reported as upstream usage; when absent the gateway derives a local estimate markedestimated. - Embed batch limit: 1 input per upstream call.
gemini-embedding-001accepts a single instance per:predictrequest (othertext-embedding-*models take more, but the limit is per-model, so the universally safe value is used); the gateway fans larger requests out over concurrent calls.
[[providers]]
name = "vertex"
kind = "vertex_ai"
# The env var holds the service-account key file's JSON contents:
# export VERTEX_SA_JSON="$(cat service-account.json)"
api_key_env = "VERTEX_SA_JSON"
base_url = "us-central1" # GCP region
[[providers.models]]
id = "gemini-flash-vertex"
upstream_id = "gemini-2.0-flash"
capabilities = ["chat"]
[[providers.models]]
id = "gemini-embedding-vertex"
upstream_id = "gemini-embedding-001"
capabilities = ["embed"]
bedrock
- kind:
bedrock· capabilities: chat, embed. - Auth: AWS Signature Version 4 (SigV4), not a bearer key. Credentials are
read from the standard AWS environment variables (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, and optionallyAWS_SESSION_TOKENfor temporary credentials) on every request, so values updated in the process environment (or a config hot reload) take effect without a restart.api_key_envis optional and, if set, overrides only the secret access key. The secret and session token are never logged or shown inDebug. - Credential scope (v1): only static keys and pre-issued STS session tokens.
There is no AWS credential-provider chain (no IMDS/instance roles, SSO,
profiles or
credential_process); an expired session token keeps failing with 403 until the environment supplies a fresh one. - API (chat): the Bedrock Converse API (
POST /model/{modelId}/converseand/converse-stream), which gives one uniform schema across the Anthropic, Meta Llama, Amazon Titan/Nova, Mistral and Cohere model families. The legacy per-modelInvokeModelchat schemas are intentionally not implemented (Converse covers the same chat models). - API (embeddings): Bedrock has no Converse equivalent for embeddings, so
the embed path uses per-model
InvokeModel(POST /model/{modelId}/invoke), routed by model id (issue #95):- Amazon Titan (
amazon.titan-embed-text-v2:0and predecessors): embeds ONE text per call ({ "inputText": ... }); the gateway loops one signed request per input and reassembles the batch in order. Titan v2 honorsdimensions(mapped to Titan’sdimensions/normalize); older Titan models do not, sodimensionsis dropped (or rejected understrict). - Cohere Embed on Bedrock (
cohere.embed-english-v3,cohere.embed-multilingual-v3): embeds a batch in one call ({ "texts": [...], "input_type": ... });input_typedefaults tosearch_documentand honors an override. Cohere does not acceptdimensions. - Batch limit: reported as
1(the conservative floor across the two families, whose real per-call limits are 1 for Titan and 96 for Cohere), so the router splits every batch into single-input sub-batches it runs concurrently. Pre-tokenized (token-id) and image inputs are rejected with an honest 400 before any upstream call (both families are text-only). - Usage (ADR 003): Titan’s
inputTextTokenCountand Cohere’sx-amzn-bedrock-input-token-countresponse header are reported as upstream usage; when absent the request edge derives the local estimate (never a silent zero).
- Amazon Titan (
- Region / base_url: set
base_urlto the runtime endpoint for your region,https://bedrock-runtime.{region}.amazonaws.com; the region is parsed back out of it for the SigV4 signing scope. VPC/PrivateLink endpoint hosts (bedrock-runtime.{region}.vpce.amazonaws.com, including avpce-…-prefixed DNS name) are recognised too. For any other custom endpoint the region comes fromAWS_REGION/AWS_DEFAULT_REGION; if no source yields a region, startup fails with a clear error rather than silently signing for a wrong region. - Translation: OpenAI ⇄ Converse is bidirectional, including system prompts,
inferenceConfig(max tokens, temperature, top-p, stop sequences), tools, and streaming. Streaming arrives as AWS event-stream binary frames, decoded and translated to OpenAI chunks. Usage (inputTokens/outputTokens) is mapped per ADR 003. Converse has no equivalent forresponse_format,seed,logprobs,parallel_tool_calls,frequency_penaltyorpresence_penalty- see chat extras and the
strictflag.
- see chat extras and the
- Images: only inline
data:URIs are supported (Converse takes raw image bytes); a remote image URL is rejected (LM-2004) since Bedrock cannot fetch one.
[[providers]]
name = "bedrock"
kind = "bedrock"
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
# api_key_env = "AWS_SECRET_ACCESS_KEY" # optional secret override
[[providers.models]]
id = "bedrock-claude-sonnet-4-5"
upstream_id = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
capabilities = ["chat"]
modalities = ["text", "image"]
[[providers.models]]
id = "bedrock-titan-embed"
upstream_id = "amazon.titan-embed-text-v2:0"
capabilities = ["embed"]
[[providers.models]]
id = "bedrock-cohere-embed"
upstream_id = "cohere.embed-english-v3"
capabilities = ["embed"]
cohere
- kind:
cohere· capabilities: chat, embed, rerank. A single model can serve any combination. - Auth:
api_key_env(e.g.COHERE_API_KEY), bearer token. - Chat: Command R / R+ via
POST /v2/chat, including streaming. The wire shape is OpenAI-adjacent (roles live directly inmessages, no top-levelsystemhoist like Anthropic;tool_callsare already OpenAI-shaped), so translation is closer to identity than Anthropic’s.tool_choicecollapses to Cohere’sREQUIRED/NONE(forcing one specific named tool has no v2 equivalent and falls back toauto).response_format,seed,frequency_penaltyandpresence_penaltymap onto Cohere’s native fields;logprobsandparallel_tool_callsdo not - see chat extras. Usage prefersusage.tokens(actual counts) overusage.billed_units(what’s charged); a response reporting neither leaves the gateway’s local estimator to fill in an honestly-flagged count (ADR 003). - Vision (issue #73): a user message carrying image parts is translated
to Cohere v2 content blocks (
text/image_url, OpenAI-shaped); a text-only message keeps the plain-string form, and non-user roles always flatten to text (Cohere only admits image content on user messages). Declaremodalities = ["text", "image"]on a vision model (Command-A-Vision) to opt in. Both inlinedata:URIs and remotehttp(s)URLs are forwarded (Cohere fetches remote URLs itself, soLM-2004does not apply); the optionaldetailhint (low/high/auto) passes through untouched. Provider-native references (anthropic-file:,gs://, Gemini Files API URIs) are rejected pre-flight withLM-2008. - Embed batch limit: 96.
- Cost: rerank is billed in search units (
cost_per_1k_searches). input_typeoverride: Cohere’s embed v2 API requires aninput_typeand the gateway cannot know query-vs-document intent, so it defaults tosearch_document(the indexing case). Setinput_typeas an extra field on the/v1/embeddingsrequest body to override it per request, e.g.{"model": "embed-multilingual", "input": "...", "input_type": "search_query"}. Allowed values:search_document,search_query,classification,clustering. An unrecognized value is rejected withLM-1001before any upstream call. The field is consumed at the gateway: only the Cohere translation reads it, and it is never forwarded in the outgoing body of any other provider (a strict OpenAI-compatible upstream such as vLLM could reject unknown fields).
[[providers]]
name = "cohere"
kind = "cohere"
api_key_env = "COHERE_API_KEY"
[[providers.models]]
id = "command-r-plus"
upstream_id = "command-r-plus-08-2024"
capabilities = ["chat"]
[[providers.models]]
id = "rerank-english"
upstream_id = "rerank-v3.5"
capabilities = ["rerank"]
cost_per_1k_searches = 2.0
[[providers.models]]
id = "embed-multilingual"
upstream_id = "embed-v4.0"
capabilities = ["embed", "rerank"]
jina
- kind:
jina· capabilities: embed, rerank (hosted). - Auth:
api_key_env(e.g.JINA_API_KEY), bearer token. - Embed batch limit: 2048.
[[providers]]
name = "jina"
kind = "jina"
api_key_env = "JINA_API_KEY"
[[providers.models]]
id = "jina-rerank"
upstream_id = "jina-reranker-v2-base-multilingual"
capabilities = ["rerank"]
voyage
- kind:
voyage· capabilities: embed, rerank (hosted). - Auth:
api_key_env(e.g.VOYAGE_API_KEY), bearer token. - Embed batch limit: 128.
[[providers]]
name = "voyage"
kind = "voyage"
api_key_env = "VOYAGE_API_KEY"
[[providers.models]]
id = "voyage-rerank"
upstream_id = "rerank-2"
capabilities = ["rerank"]
mixedbread
- kind:
mixedbread· capabilities: rerank (hosted,mxbai-rerank-*). - Auth:
api_key_env(e.g.MXBAI_API_KEY), bearer token. - base_url: optional; defaults to
https://api.mixedbread.com/v1. - Schema note: Mixedbread’s endpoint is
POST /v1/reranking(note the path:reranking, notrerank) and renames the request fields (inputinstead ofdocuments,top_kinstead oftop_n) with results nested underdata; the gateway translates transparently. - Usage: billed in tokens, so the gateway reports an
estimatedtoken count (ADR 003) rather than upstream search units.
[[providers]]
name = "mixedbread"
kind = "mixedbread"
api_key_env = "MXBAI_API_KEY"
[[providers.models]]
id = "mxbai-rerank"
upstream_id = "mixedbread-ai/mxbai-rerank-large-v1"
capabilities = ["rerank"]
pinecone
- kind:
pinecone· capabilities: rerank (hosted inference). - Auth:
api_key_env(e.g.PINECONE_API_KEY), sent as theApi-Keyheader (not a bearer token), alongside a pinnedX-Pinecone-API-Versionheader the inference API requires. - base_url: optional; defaults to
https://api.pinecone.io. - Schema note: documents are sent as
{ "text": ... }objects; only the defaulttextrank field is used (rank_fieldsselection is out of scope for v1). - Usage: Pinecone reports
usage.rerank_units, carried through verbatim as the response’ssearch_units(not estimated).
[[providers]]
name = "pinecone"
kind = "pinecone"
api_key_env = "PINECONE_API_KEY"
[[providers.models]]
id = "pinecone-rerank"
upstream_id = "pinecone-rerank-v0"
capabilities = ["rerank"]
typesafe
- kind:
typesafe· capabilities: decisions (TypeSafe’s Jev typed-decision models, and any other TypeSafe-format vendor, served onPOST /v1/decisions; see Decisions and ADR 017). Reranking goes through a virtual modelremap(Decision models as rerankers). - Auth:
api_key_env(e.g.TYPESAFE_API_KEY), sent as a bearer token. Never logged, redacted fromDebug. - base_url: optional; defaults to
https://api.typesafe.ai. An override is the API root. Withoutdecisions_patha trailing/v1is tolerated and the gateway posts to{base}/v1/systemone. decisions_path(optional,typesafeonly): the upstream path appended tobase_urlverbatim. Must start with/. Default/v1/systemone. This and the next field maketypesafethe generic TypeSafe-format kind.forward_unknown_fields(optional,typesafeonly, defaulttrue): forward unknown top-level request fields upstream, as TypeSafe accepts them. Setfalsefor a vendor that rejects them (Perplexity answers 400). Setting either field on anotherkindis refused at config load.- Schema note: the request and response are TypeSafe’s own, forwarded
verbatim for a TypeSafe-format client (only
modelis rewritten to theupstream_id);state, question and answer JSON keeps its key order byte-for-byte. The question contract is validated at the edge (LM-2011/LM-1001) before any upstream call. An OpenAI-format client is translated to this wire format. Anoulquestion withoutinstructionsis not sent to atypesafemodel (Jev requires them): the target is skipped. - Models:
jev-latest(stable alias),jev-preview(preview alias) and versioned ids such asjev-1.13.0, mapped throughupstream_id. - Errors: TypeSafe
401/422surface asLM-3003(502, not retried, upstream body not forwarded);429isLM-3001honouringRetry-After;529 Overloadedis a retryable 5xx, so retries apply, and a virtual model’sprovider_errorfallback trigger matches it (notrate_limited, which is an upstream 429). - Cost: Jev bills input tokens only, $0.042 per 1M (TypeSafe pricing,
2026-09): set
cost_per_1m_input = 0.042and no output price. - Usage: upstream
usage.input_tokens/output_tokenswhen reported; otherwise anestimatedinput count overstateplus every question (ADR 003), output 0. - Upstream limits (TypeSafe, 2026-09): 64k tokens of context per request
(32k for
stateplus the longest question), 250k tokens/s and 1200 RPM, dynamic. Not enforced by the gateway.
[[providers]]
name = "typesafe"
kind = "typesafe"
api_key_env = "TYPESAFE_API_KEY"
[[providers.models]]
id = "jev"
upstream_id = "jev-latest"
capabilities = ["decisions"]
cost_per_1m_input = 0.042
# A pinned version for reproducible answers.
[[providers.models]]
id = "typesafe/jev-1.13.0"
upstream_id = "jev-1.13.0"
capabilities = ["decisions"]
cost_per_1m_input = 0.042
# Clients call `jev-1.13.0`; it falls back to the stable alias when the pinned
# version is overloaded (529) or its circuit is open.
[[virtual_models]]
id = "jev-1.13.0"
capability = "decisions"
strategy = "fallback"
targets = [{ model = "typesafe/jev-1.13.0" }, { model = "jev" }]
# Jev as a reranker: plain /v1/rerank, one predicate question per document. A
# `typesafe` model cannot declare `rerank` itself; a virtual model's `remap`
# does the conversion (see docs/virtual-models.md).
[[virtual_models]]
id = "jev-rerank"
capability = "rerank"
strategy = "single"
[[virtual_models.targets]]
model = "jev"
remap = { strategy = "predicate" }
Other TypeSafe-format vendors
Each recipe below is a typesafe provider with its own base_url and
decisions_path. The TypeSafe format is shared, the path is not. LUMEN has
not been tested against these vendors’ live APIs: check each vendor’s
documentation for the model id and whether it tolerates unknown fields.
# Liquid d1. Unverified: set forward_unknown_fields = true if Liquid
# accepts unknown fields.
[[providers]]
name = "liquid"
kind = "typesafe"
base_url = "https://api.liquid.ai"
decisions_path = "/decisions/v1/systemone"
forward_unknown_fields = false
api_key_env = "LIQUID_API_KEY"
[[providers.models]]
id = "liquid-d1"
upstream_id = "d1"
capabilities = ["decisions"]
# Inception Mercury Decide.
[[providers]]
name = "inception"
kind = "typesafe"
base_url = "https://api.inceptionlabs.ai"
decisions_path = "/v1/decisions"
api_key_env = "INCEPTION_API_KEY"
[[providers.models]]
id = "mercury-decide"
upstream_id = "mercury-decide"
capabilities = ["decisions"]
# Self-hosted Kev (kev.serve) on its default path /v1/systemone.
[[providers]]
name = "kev"
kind = "typesafe"
base_url = "http://localhost:8000"
[[providers.models]]
id = "kev"
upstream_id = "kev"
capabilities = ["decisions"]
Upstage Solar Decide serves /v1/systemone, but its base URL is unconfirmed:
set base_url to the host Upstage documents for your account and keep the
default path. The upstream_id values above for Inception and Kev
(mercury-decide, kev) are placeholders: use the model id your vendor
documents. For Inception, Kev and Upstage the forward_unknown_fields default
(true) is untested.
Decisions by provider kind
Five kinds serve decisions. All use bearer auth except ollama (keyless;
a bearer key is sent if one is configured). Limits are checked before any
upstream call; a target that cannot take the request is skipped (see
Decisions).
kind | Endpoint | Image placement | Limits | Price per 1M input (output free) |
|---|---|---|---|---|
typesafe | {base_url} (default https://api.typesafe.ai) + decisions_path (default /v1/systemone) | none | a noul needs instructions | Jev $0.042 |
perplexity | {base_url} (default https://api.perplexity.ai) + /v1/decisions | inside state as image_url parts | 128 questions; upstream: under 262,144 input tokens, 32 MiB body, 10 RPS per org, 504 after about 60 s | $0.02 |
openai | {base_url} (default https://api.openai.com/v1) + /decisions | OpenAI input_image | choice needs at least 2 options; at most 128 images | gpt-6-luna $0.10 |
ollama | {base_url} + /v1/systemone (Ollama 0.35+) | top-level images, raw base64 | choice and score take 2 to 26 options or levels; images only on clef and clef-flash | 0 (local) |
cloudflare | Workers AI {account root}/ai/run/@cf/cloudflare/{upstream_id}, with upstream_id clef or clef-flash | top-level images | 64 questions, 4 images | Clef $0.24 |
Unknown top-level request fields are forwarded by typesafe (unless
forward_unknown_fields = false) and stripped by the other kinds; openai
sends only safety_identifier. There is no question-count cap at the gateway
edge: the per-target limits above apply when a target is chosen. Prices are
the published ones as of 2026-10 and only feed cost_per_1m_input: set it on
each model, the gateway never fills prices in. A model accepts images only if
it declares modalities = ["text", "image"] (the default is ["text"]).
perplexity and cloudflare dispatch per model, so one provider block can
hold chat models and decision models side by side. Status mapping is the
usual one (429 is LM-3001 honouring Retry-After, 5xx is retryable
LM-3003, upstream bodies never reach the client); see
Error codes. An Ollama server that is not running is
LM-3004 and falls back like any unavailable upstream.
nvidia (NIM)
- kind:
nvidia· capabilities: rerank (NVIDIA NIM ranking). - Auth: keyless by default (self-hosted NIMs run without a key); supply
api_key_env(e.g.NVIDIA_API_KEY) for the hosted API, sent as a bearer token. - base_url: required - the NIM root (e.g.
http://localhost:8000or the NVIDIA-hosted ranking endpoint root). The gateway posts to{base}/v1/ranking. - Schema note: the request nests
query: { text }andpassages: [{ text }]; there is notop_non the wire, so the gateway requests the full ranking and truncates totop_nafterwards (as for TEI). - Score semantics: NIM returns a raw logit, passed through unchanged as
relevance_score. Scores are unbounded (can be negative) and are only comparable within a single response; higher is more relevant. No sigmoid is applied. - Usage: NIM reports no token usage, so the gateway reports an
estimatedtoken count (ADR 003).
[[providers]]
name = "nvidia-nim"
kind = "nvidia"
base_url = "http://localhost:8000"
# api_key_env = "NVIDIA_API_KEY" # only for the hosted API
[[providers.models]]
id = "nvidia-rerank"
upstream_id = "nvidia/llama-3.2-nv-rerankqa-1b-v2"
capabilities = ["rerank"]
together (rerank)
The together kind (see the OpenAI-compatible section for chat/embed) also
serves rerank (LlamaRank) natively through Together’s Cohere-shaped
/rerank endpoint. One [[providers]] entry with kind = "together" serves
all three capabilities against the same base_url and bearer key. Rerank is
billed in tokens, so the gateway reports an estimated token count (ADR 003).
[[providers]]
name = "together"
kind = "together"
api_key_env = "TOGETHER_API_KEY"
[[providers.models]]
id = "llama-rank"
upstream_id = "Salesforce/Llama-Rank-V1"
capabilities = ["rerank"]
tei (self-hosted)
- kind:
tei· capabilities: embed, rerank. - Auth: keyless.
- base_url: required - points at your Text Embeddings Inference server.
TEI serves one model per process, so
upstream_idis ignored by the upstream but kept for your own clarity. - Embed batch limit: 32.
[[providers]]
name = "tei-local"
kind = "tei"
base_url = "http://localhost:8081"
[[providers.models]]
id = "bge-reranker"
upstream_id = "BAAI/bge-reranker-large"
capabilities = ["rerank"]
ollama (self-hosted)
- kind:
ollama· capabilities: chat, embed, decisions (Ollama 0.35+, onPOST {base_url}/v1/systemone;choiceandscoretake 2 to 26 options or levels, and onlyclefandclef-flashaccept images; see Local decision models with Ollama). - Auth: keyless.
- base_url: required - points at your Ollama server root (no
/v1). Embeddings use Ollama’s nativePOST /api/embed; chat goes through Ollama’s OpenAI-compatible endpoint, which lives under/v1on the same root - the gateway appends the/v1itself, so keepbase_urlas the bare server root either way. - Chat: served by the shared OpenAI-compatible path - streaming (SSE
passthrough), cancellation, and token accounting (upstream
usagewhen Ollama reports it, otherwise a local count markedestimated, ADR 003) all work exactly as for theopenaikind. api_keyasymmetry: if you set anapi_key_envon this kind (e.g. an Ollama server behind an authenticated reverse proxy), the chat path sends it as a bearer token but the native embed path currently sends no Authorization header at all - keep the embed route unauthenticated at the proxy, or front only/v1with auth.- Embed batch limit: 512.
- Tip: a local model may take a while to load into VRAM on its first call -
relax
first_token_timeout_ms/total_timeout_mson the provider block (seeconfig.example.toml). A self-hosted box on a slow link can also overrideconnect_timeout_ms; note that doing so gives this provider its own (unpooled) HTTP client (ADR 005, 2026-07-15 amendment), whereas the first-token and total overrides do not. All three fall back to their global defaults when unset.
[[providers]]
name = "ollama-local"
kind = "ollama"
base_url = "http://localhost:11434"
first_token_timeout_ms = 60000
total_timeout_ms = 120000
connect_timeout_ms = 10000 # optional: own client, relaxed connect deadline
[[providers.models]]
id = "local-llama"
upstream_id = "llama3.2"
capabilities = ["chat"]
[[providers.models]]
id = "nomic-embed"
upstream_id = "nomic-embed-text"
capabilities = ["embed"]
[[providers.models]]
id = "nimble"
upstream_id = "nimble"
capabilities = ["decisions"]
azure
- kind:
azure· capabilities: chat, embed. Reuses the OpenAI JSON schema verbatim; only the URL, auth, and routing differ fromopenai. - Auth:
api_key_env, sent as theapi-keyheader (never a bearer token). - base_url: required - your Azure resource endpoint, e.g.
https://<resource>.openai.azure.com(no shared public default, every resource is operator-specific). - api_version: optional - pins the Azure API version sent as the
api-versionquery parameter on every request (issue #65). For back-compat the older form still works: append?api-version=YYYY-MM-DDtobase_url. Precedence: the explicitapi_versionfield wins over abase_urlquery string, which wins over LUMEN’s pinned built-in default (see theazuremodule doc comment for the exact value). Any query parameters onbase_urlother thanapi-versionare ignored when building request URLs.api_versionis azure-only: setting it on any other kind is rejected at boot. - Deployment routing: Azure routes by URL path
(
/openai/deployments/{deployment}/...), not by themodelfield in the body. Set each model’supstream_idto the Azure deployment name - the sameupstream_idmechanism every other kind uses for aliasing already carries it through. - Embed batch limit: 2048 (same array-size ceiling as the OpenAI embedding models Azure hosts).
[[providers]]
name = "azure-openai"
kind = "azure"
api_key_env = "AZURE_OPENAI_API_KEY"
base_url = "https://my-resource.openai.azure.com"
api_version = "2024-10-21"
[[providers.models]]
id = "gpt-4o"
upstream_id = "my-gpt4o-deployment" # the Azure deployment name
capabilities = ["chat"]
[[providers.models]]
id = "azure-embed"
upstream_id = "my-embedding-deployment"
capabilities = ["embed"]
OpenAI-compatible hosts
groq, together, fireworks, deepseek, openrouter, perplexity, xai,
deepinfra and huggingface all work the same way: set the kind, point
api_key_env at the host’s token, and (optionally) override base_url. The
built-in default base URL is used otherwise.
[[providers]]
name = "groq"
kind = "groq"
api_key_env = "GROQ_API_KEY"
[[providers.models]]
id = "fast"
upstream_id = "llama-3.3-70b-versatile"
capabilities = ["chat"]
openrouter
Chat and embeddings, both through OpenRouter’s OpenAI-format API
(/chat/completions and /embeddings). upstream_id is OpenRouter’s
vendor/model id; the embedding catalog is listed at
https://openrouter.ai/api/v1/embeddings/models. Token counts come from the
upstream usage (OpenRouter’s extra usage.cost fields are ignored).
[[providers]]
name = "openrouter"
kind = "openrouter"
api_key_env = "OPENROUTER_API_KEY"
[[providers.models]]
id = "titan-embed"
upstream_id = "amazon/titan-embed-text-v2"
capabilities = ["embed"]
huggingface
The OpenAI-compatible Inference router (https://router.huggingface.co/v1),
distinct from the self-hosted tei kind. api_key_env holds a Hugging Face
token; upstream_id is a routed model id (often owner/model:provider).
[[providers]]
name = "hf"
kind = "huggingface"
api_key_env = "HF_TOKEN"
[[providers.models]]
id = "qwen"
upstream_id = "Qwen/Qwen2.5-72B-Instruct"
capabilities = ["chat"]
cloudflare
Cloudflare Workers AI. Chat and embeddings go through its OpenAI-compatible
endpoint; reranking (bge-reranker-* models) goes through Workers AI’s own
native POST /ai/run/{model} endpoint instead, since it is not part of the
OpenAI-compatible surface - one [[providers]] entry serves all three
capabilities against the same base_url. base_url is required because
it embeds your account id; api_key_env holds a Cloudflare API token.
The native rerank request is { query, contexts: [{ text }, ...], top_k }
(top_n is sent as top_k); the response is Cloudflare’s standard
{ result: { response: [{ id, score }, ...] }, success, errors } envelope,
with id mapped back onto the original document index. Workers AI reports no
token usage for this model; LUMEN derives a local estimate per ADR 003.
Decisions use the Clef models (clef, clef-flash) through
POST {account root}/ai/run/@cf/cloudflare/{upstream_id} on the same
base_url, with at most 64 questions and 4 images per request. HTTP 200 with
success: false is LM-3003.
[[providers]]
name = "cf"
kind = "cloudflare"
api_key_env = "CLOUDFLARE_API_TOKEN"
base_url = "https://api.cloudflare.com/client/v4/accounts/YOUR_ACCOUNT_ID/ai/v1"
[[providers.models]]
id = "cf-llama"
upstream_id = "@cf/meta/llama-3.1-8b-instruct"
capabilities = ["chat"]
[[providers.models]]
id = "cf-rerank"
upstream_id = "@cf/baai/bge-reranker-base"
capabilities = ["rerank"]
[[providers.models]]
id = "clef"
upstream_id = "clef"
capabilities = ["decisions"]
modalities = ["text", "image"]
cost_per_1m_input = 0.24
perplexity decisions
The perplexity kind also serves Perplexity’s decision models
(pplx-decider-v1.1-27b, pplx-decider-v1-27b) on POST {base_url}/v1/decisions,
next to its chat models in the same provider block. Unknown request fields are
stripped (Perplexity answers 400 to them), at most 128 questions, images go in
state. A 504 (no answer within about 60 s) is a retryable LM-3003; a 413
(body over 32 MiB) is a non-retryable LM-3003.
[[providers]]
name = "perplexity"
kind = "perplexity"
api_key_env = "PERPLEXITY_API_KEY"
[[providers.models]]
id = "sonar"
capabilities = ["chat"]
[[providers.models]]
id = "pplx-decider"
upstream_id = "pplx-decider-v1.1-27b"
capabilities = ["decisions"]
modalities = ["text", "image"]
cost_per_1m_input = 0.02
vllm
Any self-hosted OpenAI-compatible server (vLLM, llama.cpp --api, LM Studio,
SGLang, LocalAI). base_url required, API key optional. For Ollama,
prefer the native ollama kind (chat + embed, see its section above); its
OpenAI-compatible endpoint (http://localhost:11434/v1) also works under
this kind, but you lose the native embed path and the /api/version health
probe.
[[providers]]
name = "local"
kind = "vllm"
base_url = "http://localhost:8000/v1"
[[providers.models]]
id = "local-llama"
upstream_id = "meta-llama/Llama-3.1-8B-Instruct"
capabilities = ["chat", "embed"]
The developer role
OpenAI’s developer role is its newer name for system (Meilisearch chat
sends its system prompt that way). Only the openai kind receives it
verbatim; everywhere else it is handled as system, in message order with
any system messages (ADR 016):
| Kind | What the upstream receives |
|---|---|
openai (built-in URL or api.openai.com) | developer, unchanged |
anthropic, bedrock | hoisted into the top-level system |
google, vertex_ai | a systemInstruction part |
cohere | an inline system message |
mistral, azure, ollama, every OpenAI-compatible host, and openai with any other base_url (LiteLLM, llama.cpp, a proxy) | rewritten to system in place |
OpenAI chat extras on translated providers
OpenAI-compatible kinds forward every unmodeled request field verbatim, so
response_format, seed, logprobs, top_logprobs, logit_bias,
parallel_tool_calls, frequency_penalty and presence_penalty simply work
there. The translated chat kinds rebuild the upstream request field by field,
so each of these is either mapped onto a native equivalent or explicitly
unsupported (issues #72, #91) - never silently lost:
| Field | anthropic | google / vertex_ai | bedrock | cohere |
|---|---|---|---|---|
response_format | unsupported | mapped¹ | unsupported | mapped² |
seed | unsupported | mapped (generationConfig.seed) | unsupported | mapped |
logprobs | unsupported | unsupported | unsupported | unsupported³ |
top_logprobs | unsupported | unsupported | unsupported | unsupported³ |
logit_bias | unsupported | unsupported | unsupported | unsupported |
parallel_tool_calls | mapped⁴ | unsupported | unsupported | unsupported |
frequency_penalty | unsupported | mapped (generationConfig.frequencyPenalty) | unsupported | mapped |
presence_penalty | unsupported | mapped (generationConfig.presencePenalty) | unsupported | mapped |
¹ {"type": "json_object"} becomes generationConfig.responseMimeType: "application/json"; {"type": "json_schema"} additionally carries
json_schema.schema as generationConfig.responseSchema, with JSON Schema
keywords Gemini’s OpenAPI-subset schema rejects (additionalProperties,
$schema) stripped recursively. Note that additionalProperties: false is
therefore dropped, not enforced, on Gemini: the model may still emit
extra keys (each strip is logged at debug level).
² Cohere v2 has no separate json_schema type: OpenAI’s
{"type": "json_schema", "json_schema": {"schema": ...}} collapses onto
Cohere’s {"type": "json_object", "json_schema": <schema>}; json_object
and text pass through as-is.
³ Cohere v2 does accept a logprobs flag upstream, but its response shape is
not translated back to OpenAI’s (and top_logprobs rides that same response
shape), so the gateway treats both as unsupported rather than returning a
malformed response.
⁴ parallel_tool_calls: false becomes Anthropic’s
tool_choice.disable_parallel_tool_use: true (defaulting the choice to
auto when the request carries tools but no explicit tool_choice);
true is the default on both sides and needs no wire field.
What happens to an unsupported field depends on the provider’s strict
flag (the same switch Ollama uses for embeddings dimensions, issue #25):
strict = false(default): the field is dropped and adebug-level log line names the provider and field.strict = true: the request is rejected before any upstream call with an honest 400 (LM-1001) naming the field and provider - never a misleading 5xx.
[[providers]]
name = "anthropic"
kind = "anthropic"
api_key_env = "ANTHROPIC_API_KEY"
strict = true # reject response_format/seed/logprobs instead of dropping
An unrecognised response_format shape (an unknown type value) is
dropped with a debug log on the mapping providers, matching how unknown
tool_choice shapes are handled: dropped, not guessed.
Vision (image input)
POST /v1/chat/completions accepts OpenAI’s content-parts message shape, so a
user message can carry text and image parts in one array:
{
"model": "gpt-4o",
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "What is this?" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0KG..." } }
]
}]
}
image_url.url is either a data:<media-type>;base64,<payload> URI (inline
bytes) or a remote http(s) URL.
Per-model opt-in. A model only accepts image parts once its config
declares the image modality (default is ["text"]):
[[providers.models]]
id = "gpt-4o"
capabilities = ["chat"]
modalities = ["text", "image"] # opts this model into vision
GET /v1/models reflects this back as "modalities": ["text","image"] per
model. Sending an image part to a model whose modalities lack "image" is
rejected with LM-2003 (400, see docs/errors.md) before any upstream call.
Which kinds support it:
| Provider family | data: (inline base64) | http(s) URL |
|---|---|---|
OpenAI-family (openai + the OpenAI-compatible kinds) and vllm | forwarded verbatim | forwarded verbatim |
azure | forwarded verbatim (OpenAI wire schema) | forwarded verbatim |
anthropic | translated to a base64 image source block | translated to a url image source block (Anthropic fetches it) |
cohere | translated to a v2 image_url content block (data: URI forwarded inline) | translated to a v2 image_url content block (Cohere fetches it) |
google (Gemini) | translated to inline_data | rejected - LM-2004 |
vertex_ai | translated to inline_data (same as google) | rejected - LM-2004 |
bedrock | translated to a Converse image block (png/jpeg/gif/webp) | rejected - LM-2004 |
Never-fetch rule. LUMEN never dereferences a user-supplied image URL
itself - doing so would be an SSRF vector (the gateway could be aimed at
internal addresses) and would violate the streaming/latency pillar. A remote
http(s) image_url is only ever forwarded to a provider that fetches it
itself (OpenAI, Anthropic); Gemini’s inline_data field takes only inline
bytes, so a remote URL routed to Gemini is rejected with LM-2004 (400)
instead of the gateway silently fetching it on the caller’s behalf.
The LM-2004 pre-flight check inspects the primary provider of the model’s
fallback chain. In the uncommon case where the primary accepts remote URLs
(e.g. OpenAI) but a Gemini model is configured as a fallback, a request with a
remote image URL passes pre-flight and, only if the primary then fails over to
Gemini, surfaces as an upstream LM-3002 (502) - the gateway still never
fetches the URL. Configure inline data: URIs when a Gemini fallback is in play.
Provider-native image sources (issue #12). Two provider-native reference
forms are recognised in the image_url.url field, for callers whose images
are already uploaded to the provider:
Reference form in url | Translated for | Becomes |
|---|---|---|
anthropic-file:<file_id> | anthropic | source: {type: "file", file_id} (Anthropic Files API) |
https://generativelanguage.googleapis.com/... (Gemini Files API URI) | google | fileData.fileUri |
gs://bucket/object (Cloud Storage URI) | google | fileData.fileUri |
A provider-native reference routed to a model whose primary provider is
not the reference’s own provider is rejected pre-flight with LM-2008 (400) -
an honest client error instead of a confusing upstream failure. A Gemini
Files API URI is also an https:// URL, but it is exempt from the LM-2004
remote-URL check: it is not a URL the provider would have to fetch, and its
routing verdict belongs to the LM-2008 check.
gs://caveat. The gateway forwards ags://URI to Gemini verbatim, but the Gemini Developer API (generativelanguage.googleapis.com, the defaultbase_urlof thefileData.fileUrifor its own Files API URIs; Cloud Storagegs://URIs are a Vertex AI capability. Against the default endpoint ags://reference is passed through and will be rejected by the upstream (surfacing as an upstream error namingLM-2008),base_urlmay point at a Vertex-compatible gateway, and the upstream - never the gateway - is the authority on which URI forms it accepts. Upload via the Gemini Files API and pass the returned URI when targeting the Developer API.
For the mime type of a fileData part: it is included only when it can be
confidently inferred from the URI’s file extension (.png, .jpg, …);
otherwise it is omitted rather than guessed. Files API URIs carry no
extension, and Gemini already knows the mime type recorded at upload time.
Accounting. Upstream-reported usage is authoritative and already folds in
image tokens. When an upstream reports no usage at all, the local estimation
fallback counts each image content part with a flat per-image heuristic (85
tokens at "detail": "low", 765 tokens otherwise) rather than counting it as
zero, and the response is still flagged "estimated": true - see the
ADR 003 addendum.
Fallbacks across providers
A virtual model with the fallback strategy backs one
model with others when its provider exhausts retries or its circuit is open
(or on any other trigger you list in fallback_on). Each target must exist and
serve the virtual model’s capability (validated at boot and on every reload),
which lets you survive a single-vendor outage by spanning providers:
[[providers.models]]
id = "openai/gpt-4o"
upstream_id = "gpt-4o"
capabilities = ["chat"]
[[virtual_models]]
id = "gpt-4o"
capability = "chat"
strategy = "fallback"
targets = [{ model = "openai/gpt-4o" }, { model = "claude-sonnet-4-5" }] # different vendor, same capability
See docs/adr/005-resilience-execution.md
for the resolution and circuit-breaker details, and config.example.toml for a
fully worked multi-provider setup including a three-vendor rerank fallback chain.
Team provider keys (BYOK)
On the hosted gateway a budget group (one Lab account) can bring its own API keys for a provider (ADR 018). Only kinds that take a plain API key on the vendor URL are eligible: openai, anthropic, mistral, cohere, jina, voyage, groq, together, fireworks, deepseek, openrouter, perplexity, xai, deepinfra. Azure, Bedrock, Google/Vertex and keyless kinds are not.
- A group holds up to 10 keys per provider, managed under
/admin/groups/{id}/byok(keys are sealed at rest and onlylabel,last4and status are ever returned). - Keys are used round-robin. A 429 benches a key for
max(Retry-After, 30s); a 401/403 marks it failing for 10 minutes. - When no team key is usable, the request goes to the platform’s key only if
the provider’s
fallbackis on (default off); otherwise it fails withLM-3021(429) without calling upstream. With fallback off, later links of a fallback chain that have no team pool also refuse, so a chain never reaches the platform’s keys. - Team-served requests carry no token cost; the gateway bills bandwidth instead (request plus response bytes, only when an upstream call was sent).
- A link served from a group’s team keys has its own circuit breaker, keyed
by (provider, model, group): platform-key failures (say a run of 429s on
the platform key) never stop a team served on its own keys, and a team’s
failing keys never open the breaker for platform traffic. The
lumen_circuit_stategauge reports platform breakers only. - When an earlier link of a chain failed upstream and a later link is refused
because the fallback is off, the client gets the earlier error, not
LM-3021.
Error codes
Every error LUMEN returns to a client carries a stable LM-XXXX code, an
HTTP status, and a coarse type. The response body is always:
{ "error": { "code": "LM-1001", "message": "…", "type": "invalid_request" } }
The type is one of invalid_request, upstream_error, internal, or
client_cancelled. The gateway always distinguishes these situations and
never disguises one as another - in particular, an internal malfunction is
never reported as a misleading 401 (a lesson from OpenRouter outages), a
malformed upstream response is a 502, never a gateway 500, and a
client-initiated cancel (client_cancelled) is never counted as an internal
malfunction either (see LM-6xxx below).
Codes are stable: once assigned, a code keeps its meaning across releases. The
code prefix groups by cause: 1xxx request, 2xxx routing, 3xxx upstream,
4xxx auth/budget, 5xxx internal, 6xxx client-cancellation.
Request errors - LM-1xxx · type: invalid_request
| Code | HTTP | Meaning |
|---|---|---|
LM-1001 | 400 | Malformed or invalid request body / parameters. Also returned when a provider in strict mode is sent an unsupported-but-meaningful field it cannot honor (e.g. dimensions to Ollama, or response_format/seed/logprobs/parallel_tool_calls to a translated chat provider that has no native equivalent - see the chat-extras matrix), and when an input shape a provider cannot consume at all is sent (pre-tokenized token-id arrays to a text-only embed API: Cohere, TEI, Ollama, Jina, Voyage, Mistral). Both are rejected before any upstream call; the message names the field/shape and provider. Also covers every rejection of a config write under /admin/config* (ADR 012, both config_source modes) that is not specifically LM-1003 or LM-1004: a missing If-Match header on any mutating call, granular included; a candidate document that changes a restart-only boot-layer key (server.*, log_format, telemetry.*, auth.enabled, auth.db_path, the usage-log channel knobs, usage_events, config_source itself) - the message names the changed key(s) and says “edit the boot config file and restart”; in config_source = "db" mode, a candidate that carries any boot-layer key at all, even at its default value (the message names the key); a candidate that fails validation (bad TOML, an unknown field, an invalid virtual model such as a cycle, a missing target, a bad when or a mismatched remap, see Virtual models); a config that still uses the removed per-model fallbacks, [providers.models.rerank] or a rerank capability on a typesafe model, which names the model and points to lumen config migrate; PUT /admin/config/providers/{name} where the path name and the body’s own name disagree; a delete that would leave a virtual model reference dangling (DELETE /admin/config/providers/{name} of a provider, or DELETE /admin/config/virtual_models/{id} of a model, that a virtual model still targets), naming the dependent virtual model; PUT /admin/config/virtual_models/{id} where the path id and the body’s own id disagree; and a PUT /admin/config/auth body naming a boot-layer [auth] field (enabled, db_path, or a usage-log channel knob), since that route accepts only the 2 dynamic knobs (flush_interval_ms, retention_days). Also covers a choice remap rerank (a Jev virtual model, see Virtual models) over 255 documents or whose documents do not fit one upstream call, rejected before any upstream call. Also covers every POST /v1/decisions request-contract violation other than empty questions (that one is LM-2011), see Decisions: a body that is not JSON, or that is neither format (the message reads “send either OpenAI format (input, questions array) or TypeSafe format (state, questions object)”); an unknown top-level parameter in an OpenAI-format body; a null state, or a question with a missing or unknown type, missing instructions, or malformed criteria or choices or levels; and a request that no target in the chain can take, found before any upstream call (more questions than a target allows, such as Perplexity’s 128 or Cloudflare’s 64; more than 4 images for Cloudflare; a choice with one option for OpenAI or Ollama, or with more than 26 options or levels for Ollama; a noul without instructions for a typesafe model; a choice holding both "true" and true). The message names the offending question and, for a target limit, the provider; with several targets, an incompatible one is skipped and the error surfaces only when none is left. A provider that sets decisions_path or forward_unknown_fields while its kind is not typesafe, or a decisions_path that does not start with /, is refused at config load (the process does not boot) and, through the admin config writes, with this code. Also returned by the key and group admin routes for an invalid control-plane ref (ADR 015): an empty or over-128-character external_ref or account_ref, or an account_ref that is not a UUID while [usage_events] is configured; the message names the field. The same routes (create and PATCH) refuse a blank name, a negative budget_max, rpm_limit, tpm_limit or expires_at, and a budget_max above 1e12 USD, naming the field. Also returned by PUT /admin/webhooks for a receiver url whose host is internal (loopback, private, link-local, unique-local or otherwise reserved, a localhost name, or a hostname resolving to such an address or not resolving at all) and not listed in LUMEN_WEBHOOK_ALLOWED_HOSTS, and by PUT /admin/config or PUT /admin/config/webhooks when the write introduces or changes such a [webhooks] receiver; the message names the host and the variable. Also returned by the key, group and usage admin routes for an X-Lumen-Account-Ref header that is not a UUID (platform contract v2 section 8.3; a malformed value is refused, never read as “unscoped”), and on an account-scoped call for a key creation without group_id, a group creation whose account_ref differs from the header, a key PATCH setting group_id to null, and a group PATCH that sets account_ref. Also returned by the team key pool routes (/admin/groups/{id}/byok/...) for a provider kind that does not take a team key (only plain-API-key kinds do), a key shorter than 16 characters (after trimming) or longer than 1024 bytes, a label outside 1 to 64 characters, and an 11th key for one (group, provider). |
LM-1002 | 413 | Request body exceeded the configured size limit. |
LM-1003 | 404 | No route matches the request method and path. Returned by the router fallback for trailing-slash, extra-segment and other near-miss paths, so an unmatched request carries the same envelope as every other rejection instead of a bare, empty-body 404. The message names no path and discloses no route. The fallback sits outside the virtual-key auth layer, so an unmatched path answers 404 even when auth is enabled (it leaks no more than the bare 404 did); a matched /v1 route without a key is still LM-4004. Distinct from LM-2001, which means the HTTP route matched but the requested model id does not exist. Also returned by the granular config surface (ADR 012) for a provider name GET/PUT/DELETE /admin/config/providers/{name} does not recognize in the current document, and for a virtual model id GET/DELETE /admin/config/virtual_models/{id} (and .../{id}/plan) does not recognize in the current document, and for a section name GET/PUT /admin/config/{section} outside the fixed set (resilience, tokenizer, image_fetch, webhooks, auth; telemetry is boot-layer and has no granular route) - the same per-entity-404 style every other admin lookup in LUMEN uses. Also returned by POST /admin/providers/{name}/check for a provider name the live routing table does not contain (the check’s own verdict, including a rejected provider key, is data in a 200, never an error code). Also returned, with a message naming the id, by PATCH/DELETE /admin/keys/{id}, POST /admin/keys/{id}/rotate, POST /admin/keys/{id}/grant, PATCH/DELETE /admin/groups/{id} and POST /admin/groups/{id}/grant for an id that does not exist or is a soft-deleted tombstone (these were 400 LM-1001 before), and by PUT /admin/provider-keys/{name} for a provider name the current config document does not define (nothing is stored). GET /admin/groups/{id} answers the same 404 for an unknown or deleted group id. On a call carrying X-Lumen-Account-Ref (platform contract v2 section 8.3), a key or group of another account, and a key with no group, get this exact 404 too, so a scoped caller cannot tell a foreign id from one that never existed; so does a group_id of another account in a scoped key creation or PATCH. The team key pool routes (/admin/groups/{id}/byok/...) answer it for an unknown or out-of-scope group (same as the other group routes), an unknown provider and an unknown team key id. |
LM-1004 | 412 | The submitted If-Match no longer matches the current config document’s hash - the document changed since it was read, most often another operator’s apply landing first (or, in config_source = "file" mode, an external edit: a human or a GitOps sync). Applies to every mutating call under /admin/config* (ADR 012): the whole-document PUT /admin/config and every granular write (PUT/DELETE /admin/config/providers/{name}, PUT /admin/config/{section}) alike, in both config_source modes. The hash is source-verbatim in both modes - file mode: the whole config file’s bytes; DB mode: the stored config_versions document, or the empty document’s hash before the first PUT - so If-Match semantics are identical either way and a client cannot tell which mode it is talking to. The request is refused before anything is staged, patched or written; re-GET the resource for the current hash and content, then re-apply. Kept distinct from a missing/malformed If-Match header, which is a plain LM-1001 400: the console needs to tell “you sent no token” from “your token is stale” apart, since only the second case calls for a re-read-and-retry rather than a client bug fix. |
Routing & capability-request errors - LM-2xxx · type: invalid_request
| Code | HTTP | Meaning |
|---|---|---|
LM-2001 | 404 | The requested model id (foundation or virtual) was not found. |
LM-2002 | 400 | The model (foundation or virtual) exists but does not serve the requested capability. |
LM-2003 | 400 | An image content part was sent to a model without the image modality (chat vision M8, embeddings M9 and /v1/decisions). For a decisions request routed through a virtual model, the message names the leaf model that could not take the image, not the virtual id; targets without image are skipped first and this surfaces only when none is left. |
LM-2004 | 400 | A remote image URL was sent to a provider that only accepts inline base64 image data (chat vision M8). /v1/decisions accepts data: URLs only, so any http(s) image URL in either format is LM-2004. Checked before any upstream call for the primary route; if a fail-over reaches an image-incapable fallback further down the chain, the same code and status surface there too, naming the fallback provider - never the generic LM-3002 a translation failure would otherwise produce (GH #13). |
LM-2005 | 400 | A remote image URL was supplied to /v1/embeddings but server-side image fetching is disabled ([image_fetch] enabled = false). Inline the image as a data: URI or enable fetching (M9). |
LM-2006 | 400 | A remote image URL was rejected by a fetch guard (scheme, host/prefix allowlist, private-IP block, size cap, per-request count cap, or non-image content type). The specific reason is logged server-side, never returned (M9). |
LM-2007 | 502 | A permitted image fetch failed at the remote host (network error, timeout, or error status). type: upstream_error (M9). |
LM-2008 | 400 | A provider-native image source (Anthropic file_id, spelled anthropic-file:<id>; Gemini fileUri, a gs:// GCS URI or a Gemini Files API URI) was sent to a provider that cannot resolve it - the resolved primary provider must match the reference’s own provider. |
LM-2010 | 400 | A rerank request supplied no documents to score. |
LM-2011 | 400 | A decisions request (POST /v1/decisions) supplied empty questions: an empty array in the OpenAI format, an empty map in the TypeSafe format. Rejected before any upstream call; other decisions contract violations are LM-1001 (ADR 017, Decisions). The code is unchanged from the SystemOne era. |
LM-2012 | 4xx | The upstream rejected the input as longer than the model’s context window (classified from its error body, ADR 014). Names the provider; the HTTP status is the upstream’s (400, 403, 413 or 422). A virtual model can fail over on it with fallback_on = ["context_length"]; this code surfaces when no target absorbed it. |
LM-2013 | 4xx | The upstream refused the request on content policy, either with an error body (classified, ADR 014) or, for a choice rerank remap, with a refusal answer in a 200 response (ADR 017). Names the provider. The HTTP status is the upstream’s when it was a 4xx (usually 400 or 403), else 400 (a refused rerank choice is always 400). A virtual model can fail over on it with fallback_on = ["content_filter"]. |
Upstream errors - LM-3xxx · type: upstream_error
These always name the provider that failed. Retriable ones may be transparently retried on a fallback before surfacing.
| Code | HTTP | Meaning |
|---|---|---|
LM-3001 | 429 | An upstream provider rate limited the request. |
LM-3002 | 502 | An upstream provider returned an unparseable/malformed response. |
LM-3003 | 502 | An upstream provider returned an error status. Also a Cloudflare Workers AI decisions call answered HTTP 200 with success: false (the upstream errors[].message is kept out of the client response), and a Perplexity 413 (body over 32 MiB) or 504 (no answer within about 60 s; the 504 is retryable). For an upstream 4xx, the provider’s own message is usually in the preceding upstream returned an error log line (field upstream_error, same request_id; bounded, credentials redacted, never sent to the client). That line is absent when the upstream body carries no JSON message string. |
LM-3004 | 503 | No healthy upstream available (circuit open / fallbacks spent). |
LM-3005 | 504 | An upstream provider timed out. |
LM-3010 | 502 | An upstream stream ended prematurely (no terminator). |
LM-3011 | 504 | An upstream produced no first token within the first-token deadline. |
LM-3012 | 504 | The connection to an upstream could not be established within the connect timeout. |
LM-3013 | 504 | The whole request (all retries + fallbacks) exceeded the total timeout. |
LM-3020 | 503 | The provider’s circuit breaker is open and no fallback remained. |
LM-3021 | 429 | Every team (BYOK) key of the caller’s group for the provider is rate-limited or failing, and the group’s fallback to the platform’s keys is off. |
For LM-3001, LM-3020 (and LM-4002/LM-4003), a Retry-After value may be
advertised. The three timeouts (LM-3011 first-token, LM-3012 connect,
LM-3013 total) are distinct codes purely for debugging - see §6.4 and
docs/adr/005-resilience-execution.md.
How resilience shapes these codes
The 3xxx codes are what a client sees only after the resilience machinery
has given up. Before surfacing, a retryable failure (LM-3001 429,
LM-3003 5xx, LM-3005/LM-3012 timeouts) is retried with exponential
backoff, then the request fails over to the next target of the virtual model (its fallback_on triggers decide which failures move on, see Virtual models).
The mapping between a failure and the code that eventually surfaces:
LM-3020(503) - the primary’s circuit is open and no fallback remained. Skipping an open circuit is instant (no upstream call), and the response carries aRetry-Afterequal to the cooldown remainder. A link served from a group’s team keys (BYOK) has its own breaker, apart from the platform key’s, so one never opens the other.LM-3021(429) - the caller’s group brought its own keys for the provider and none is usable right now (rate-limited keys are benched formax(Retry-After, 30s), keys the provider rejects with 401/403 for 10 minutes). Decided by the gateway, so it is never retried and never counts against the circuit breaker. A fallback chain may move on under itsrate_limitedtrigger, but a later link only runs if the group also serves it with its own keys: with the fallback off, no link reaches the platform’s keys. When an earlier link of the chain already failed upstream (say the team key answered 503), a later link refused this way surfaces that earlier error instead, so the refusal never hides the real failure. The message tells the team to add a key or turn on the fallback; it never names a key.LM-3004(503) - every link in the fallback chain was tried and failed (retries exhausted or circuits open all the way down).LM-3013(504) - the total per-request deadline elapsed while retrying or failing over; it bounds all attempts together, so a slow chain fails here rather than hanging.LM-3011/LM-3012(504) - first-token and connect timeouts; each is a retryable failure on its own before it surfaces.
A hard upstream client error (a 4xx bad request) is never retried or failed
over - a different provider would reject it too - and surfaces immediately.
Whichever model ultimately served a successful request is reported in the
x-lumen-model-used response header.
Auth / budget errors - LM-4xxx · type: invalid_request
Codes pinned by the spec. Enforcement happens in memory, before any upstream call - a rejected request never leaks spend to a provider.
| Code | HTTP | Meaning |
|---|---|---|
LM-4001 | 402 | A hard budget the key is subject to is exhausted: either the key’s own budget_max or its budget group’s shared pool (ADR 009). Same code and status for both; the message text discloses the scope (“budget exceeded for this key” vs “budget exceeded for this key’s group”). |
LM-4002 | 429 | The key’s requests-per-minute quota was exceeded. |
LM-4003 | 429 | The key’s tokens-per-minute quota was exceeded. |
LM-4004 | 401 | Missing or invalid virtual key. Deliberately does not say why (unknown, disabled and expired are indistinguishable) so callers cannot probe key state. |
LM-4005 | 403 | The call carried X-Lumen-Account-Ref (an account-scoped control-plane call, platform contract v2 section 8.3) on a platform-only route: /admin/provider-keys/*, /admin/providers/*, /admin/webhooks*, /admin/config*, /openapi.json, GET /health/providers and GET /metrics (both need no key, but describe the whole platform: provider names and health, and metric labels across every account). Keys, groups and usage routes accept the header and are filtered by it. |
Internal errors - LM-5xxx · type: internal
| Code | HTTP | Meaning |
|---|---|---|
LM-5001 | 500 | Internal gateway malfunction. |
Internal errors return an opaque "internal error" message to the client; the
underlying detail is written only to the server logs, never the response.
Client-cancellation - LM-6xxx · type: client_cancelled
| Code | HTTP | Meaning |
|---|---|---|
LM-6001 | 499 | The client disconnected before the request completed; the upstream call was aborted. |
499 is the conventional “client closed request” status (nginx). The client
is normally already gone by the time this would be returned, so the status
exists for logs and metrics, not for anything a client reads. It is
deliberately kept out of both type: internal and the 5xx status class: a
client hanging up is not a gateway malfunction, and must never inflate the
internal-error metrics or alerts a real one would (issue #11, see
docs/adr/006-client-cancellation-error-code.md).
Two paths produce it. A cancellation surfacing mid-stream is emitted as a
terminal SSE error frame carrying this envelope. A client that simply
disconnects mid-stream never sees a frame at all, but the request’s
accounting record (usage_log.status and the
lumen_request_duration_seconds{status="499"} sample) is settled at 499
instead of being miscounted as a 200 success. A non-streaming disconnect
drops the request before any outcome is recorded and produces no sample.
Performance baseline
LUMEN’s first pillar is performance: < 1 ms added p99 off-network, ~15 MB idle RAM, streaming that doesn’t re-serialize. This document records how those promises are measured, the numbers obtained, and how to reproduce every figure. Where a target is not fully measured in a given environment, the gap is stated honestly rather than papered over.
Methodology
Two layers, because “added latency” has two very different scales:
-
In-process overhead (
cargo bench) - the CPU work the gateway adds per request, with no sockets involved: the resilience executor wrapping a provider call (circuit-breaker admit → retry loop → per-attempt timeout) plus the JSON (de)serialization the OpenAI surface does. This is the honest measure of “added latency off-network”: it excludes the upstream and the network entirely. Criterion, warmed, reports median with a 95 % CI. -
End-to-end head-to-head (
bench/, docker-compose + k6) - LUMEN and LiteLLM both proxying the same zero-latency mock upstream, driven by k6. Added latency = gateway percentile − direct-to-mock percentile. This is the number a user feels; it includes one extra localhost hop. Provided as a reproducible harness (seebench/README.md). Reported at two marks per target: total request time (http_req_duration) and time to first byte (http_req_waiting: request fully written → first response byte). -
Streaming time to first bit (
cargo bench -p server --bench stream_ttfb) - the one latency k6 cannot see: how much later a client receives the first SSE chunk of astream: trueresponse because the gateway sits in the middle. Real sockets, full LUMEN stack (axum → router → OpenAI-kind provider), instant mock upstream; the same request is timed direct-to-upstream and via-gateway, and the difference between the two distributions is the gateway’s added streaming TTFB. The companion integration test (tests/chat.rs::first_stream_chunk_reaches_the_client_before_the_upstream_finishes) proves the first frame is forwarded while the upstream is verifiably still mid-stream (gated tail, no timing races), so this bench measures eager forwarding, not buffer-then-flush.
Environment of the recorded run
| Machine | Apple Silicon (arm64), macOS |
| Toolchain | rustc 1.97.0, release profile (lto = "thin", codegen-units = 1) |
| Command | cargo bench -p server --bench gateway_overhead |
Numbers are hardware-specific; re-run the commands on your target to get yours.
Results - in-process overhead (measured here)
| Bench | Median | 95 % CI |
|---|---|---|
executor_overhead_chat (executor around an instant provider) | 1.21 µs | 1.04 – 1.40 µs |
json_request_deserialize (parse a chat request) | 1.34 µs | 1.15 – 1.55 µs |
json_response_serialize (serialize a chat response) | 0.60 µs | 0.55 – 0.66 µs |
systemone_request_pipeline_small (SystemOne parse + validate + estimate + one attempt’s clone and serialize; 1 KB state, 3 questions; ADR 013) | 3.3 µs | 3.1 – 3.6 µs |
systemone_request_pipeline_large (same, at the documented maximum: 128 KB state, 300 questions) | ~280 µs | 241 – 322 µs |
Total added CPU per non-streaming chat request ≈ 3.2 µs (executor + parse +
serialize). Streaming passthrough adds even less per chunk: it forwards upstream
Bytes verbatim with no per-chunk serde (ADR 004), so the per-chunk cost is a
bounded copy plus the [DONE]/heartbeat scan, not a deserialize.
SystemOne cost scales with the body: a typical request matches chat, while a
maximum-size one (2026-09 measurement, noisy host) spends roughly 100 µs in
the unavoidable JSON scan and the rest in per-question raw copies and the
upstream re-serialization. It stays inside the 1 ms budget; a byte-splice
passthrough that would roughly halve the large case is in docs/backlog.md.
The large case needs CARGO_PROFILE_RELEASE_STRIP=false to build on macOS
27, where strip = true corrupts proc-macro dylibs (pre-existing, unrelated).
Decisions (ADR 017)
Same machine and toolchain as above (Apple Silicon arm64, macOS, rustc 1.97.0,
release profile). Command: cargo bench -p server --bench gateway_overhead -- decisions
(add CARGO_PROFILE_RELEASE_STRIP=false on macOS 27). Passthrough is parse +
token estimate + one attempt’s clone and encode to the same vendor; cross-vendor
is parse + encode to the other vendor’s wire format. “Large” is a big body, not
a gateway limit (there is no question-count cap at the edge): 300 questions and
128 KB of state for TypeSafe (same shape as the old SystemOne large case), and
128 questions (chosen for the case) and 128 KB of input for OpenAI.
| Bench | Median | 95 % CI | Before the final fixes |
|---|---|---|---|
decisions_passthrough_typesafe_small (1 KB, 3 questions) | 3.23 µs | 3.22 – 3.23 µs | 3.48 µs |
decisions_passthrough_typesafe_large (128 KB, 300 questions) | 280.0 µs | 279.6 – 280.4 µs | 307.1 µs |
decisions_passthrough_openai_small | 2.87 µs | 2.86 – 2.90 µs | 3.00 µs |
decisions_passthrough_openai_large | 152.5 µs | 152.2 – 152.9 µs | 155.8 µs |
decisions_cross_vendor_typesafe_small (TypeSafe in, OpenAI out) | 4.56 µs | 4.55 – 4.56 µs | 4.62 µs |
decisions_cross_vendor_typesafe_large | 417.2 µs | 416.8 – 417.6 µs | 414.3 µs |
decisions_cross_vendor_openai_small (OpenAI in, TypeSafe out) | 2.80 µs | 2.77 – 2.86 µs | 3.10 µs |
decisions_cross_vendor_openai_large | 149.8 µs | 149.5 – 150.1 µs | 169.8 µs |
The final fixes (pre-sized output buffers, the name-only wire_ids fast
path, a borrowed structured state in the OpenAI encoder, the question type
borrowed at the TypeSafe edge) cut the TypeSafe large passthrough by 9 %
(307 to 280 µs) and the OpenAI-to-TypeSafe large translation by 12 % (170 to
150 µs). The TypeSafe-to-OpenAI large translation is flat (414 to 417 µs,
within run-to-run noise: a same-session run of the previous commit measured
415.6 µs); its cost is the 300 per-question instruction strings the OpenAI
wire needs, not buffer growth. Every case is below the 1 ms budget; the worst
is 417 µs. The TypeSafe large passthrough (280 µs) now matches the old
systemone_request_pipeline_large (~280 µs), and the small one (3.23 µs) is
within 2 % of the old systemone_request_pipeline_small (3.3 µs).
Streaming time to first bit (measured here)
Recorded with cargo bench -p server --bench stream_ttfb in the same
environment as above: the span from dispatching a stream: true chat request
to reading the first bytes of the SSE body, over real loopback sockets,
against an instant mock upstream.
| Bench | Median | 95 % CI |
|---|---|---|
direct_to_upstream (client → mock, no gateway) | 71.6 µs | 70.6 – 71.9 µs |
via_gateway (client → full LUMEN stack → same mock) | 168.4 µs | 158.0 – 175.9 µs |
Added streaming TTFB ≈ 97 µs median (~0.1 ms): the extra wait before a client sees the first streamed token because LUMEN sits in the middle. That buys one full extra HTTP hop (accept, parse, route, provider request build, upstream connect-pooled call, headers + first-frame forward), measured end to end, and still lands an order of magnitude inside the < 1 ms pillar. The companion integration test (methodology point 3 above) guarantees the number means what it says: the first frame is forwarded while the upstream is still mid-stream, never buffered until end-of-stream.
Budget accounting per request (measured here)
Recorded 2026-10 with cargo bench -p auth --bench admit_settle (rustc
1.97.0, same machine): one admit plus its settle on a single thread, the
in-memory budget accounting every authenticated request pays (ADR 009, and
the ADR 015 settled-cost counter that billing reads). Lock-free atomics, no
I/O; the database is never touched on the request path.
| Bench | Median | 95 % CI |
|---|---|---|
admit_settle_key (one key with a budget) | 36.4 ns | 36.37 – 36.45 ns |
admit_settle_key_in_group (the same key in a budgeted group: the pool is reserved and settled too) | 54.7 ns | 54.58 – 54.82 ns |
admit_settle_neighbour_keys_parallel (8 threads on an 8-core Apple M1 Pro, each on its OWN key, keys picked as consecutive allocations; per-thread cost of one pair) | 57.0 ns | 53.9 – 60.7 ns |
Well under a microsecond either way: budget enforcement and billing
accounting are noise next to the ~3.2 µs of request CPU above. Like the
SystemOne bench, it needs CARGO_PROFILE_RELEASE_STRIP=false to build on
macOS 27.
The parallel bench guards against false sharing between tenants. Keys are
loaded back to back at boot, so before KeyEntry and GroupEntry were
aligned to the cache line (128 bytes on x86_64 and aarch64, 64 elsewhere)
two neighbouring keys’ hot atomics, or one key’s tail and the next key’s
Arc refcount, shared a line: the same bench measured 100.1 ns
(96.8 – 103.4 ns), 2.5x the single-thread admit_settle_key of that run
(39.3 ns) for keys that never contend logically. Aligned, it is 57.0 ns
(-47 %), and the single-thread benches are unchanged within noise. The
remaining gap to one thread (about 1.5x) was not investigated; it is not
sharing between keys (the entries no longer share a line), and the M1 Pro
runs two of the eight threads on efficiency cores. An interleaved A/B
(unpadded and padded bench binaries run alternately, 4 rounds, on a heavily
loaded machine, load average 54 to 59) agreed: the parallel bench was faster
padded in every round (median about 127 ns to 77 ns), and the best
single-thread admit_settle_key round was the same either way (38.5 ns
unpadded, 38.4 ns padded). The price is padding, paid once per key at
load: a key’s allocation grows from 184 to 384 bytes (about 2 MB per 10,000
keys), a group’s to 256 bytes.
Idle memory & binary size (measured here)
Idle RSS (release binary, one provider, after serving /health) | ~8.8 MB (9040 KB) |
| Binary size (macOS arm64, release) | ~7.7 MB |
| Docker image (distroless/static + musl binary, arm64) | 10.6 MB |
The Docker image was built from Dockerfile and smoke-tested: docker run -v config.toml -e OPENAI_API_KEY … answers /health 200 and /v1/models. At
10.6 MB it is well under the 30 MB image budget (§7.2). The amd64 image is built
in CI via buildx (release.yml).
Results - loaded head-to-head vs LiteLLM (recorded baseline)
Recorded by bench/run.sh (see bench/README.md); full raw output is
committed at bench/results/20260715T231135Z/.
| Target | p50 | p95 | p99 | req/s |
|---|---|---|---|---|
| direct (mock, no gateway) | 6.91 ms | 204.99 ms | 836.43 ms | 1191.7 |
| lumen | 9.44 ms | 36.57 ms | 220.57 ms | 2733.5 |
| litellm v1.92.0 | 323.75 ms | 656.67 ms | 6490.29 ms | 111.3 |
Time to first byte for the same run (http_req_waiting; derived from the
committed raw *.summary.json of that run - the metric was always recorded,
its report table was added later, so the run’s own report.md predates it):
| Target | TTFB p50 | TTFB p95 | TTFB p99 |
|---|---|---|---|
| direct (mock, no gateway) | 6.81 ms | 204.91 ms | 836.42 ms |
| lumen | 9.38 ms | 36.34 ms | 220.17 ms |
| litellm v1.92.0 | 323.71 ms | 655.39 ms | 6490.04 ms |
TTFB tracks total duration almost exactly in this scenario (non-streaming, tiny mock body: once the first byte is out, the rest follows within microseconds), so it carries the same caveat and the same conclusion: LUMEN delays the start of the response by ~2.6 ms at p50 on this noisy host, LiteLLM by ~317 ms.
RAM under load (docker stats, sampled mid-run): lumen ~7.6 MB,
litellm ~1.03 GB.
Environment this specific run was recorded in: Darwin arm64, Docker 29.4.0,
k6 v2.0.0, LUMEN at commit 51fc809, LiteLLM
ghcr.io/berriai/litellm:v1.92.0@sha256:9ef6f45bc0104940571765e610c52a1d761b5ec85efcd193795281086ee61277,
mockserver 5.15.0@sha256:0f9ef78c94894ac3e70135d156193b25e23872575d58e2228344964273b4af6b.
Caveat, stated honestly: this run was recorded on a shared development
host (not dedicated benchmarking hardware), which is visible in the noisy
direct-baseline numbers above (a 0 ms mock should not itself show ~836 ms
p99; the mockserver JVM saturates a core by itself, so the direct phase
measures host contention as much as transport). Treat the absolute
numbers as illustrative, not authoritative. The relative comparison -
lumen vs litellm, same mock, same host, same run - is the meaningful part:
LUMEN added ~2.5 ms at p50 over direct (9.44 vs 6.91 ms) and its tail stayed
below the noisy direct baseline, while LiteLLM’s p50 grew by ~47× (323.75
ms) and its p99 reached 6.5 s. LUMEN sustained ~25× LiteLLM’s throughput at
roughly 1/140th the RAM. Re-run bench/run.sh on dedicated hardware for
numbers to make capacity decisions on; see “Updating the pinned versions” in
bench/README.md for how to refresh this baseline (new pinned image, new
results/ directory, update the link above).
Targets
| # | Target | Status |
|---|---|---|
| 1 | < 1 ms added p99 off-network | Met (median), with margin. The gateway’s per-request CPU work is ~3.2 µs median; even a 100× tail would sit at ~0.3 ms, well under 1 ms. Under concurrent load and a real localhost hop (the k6 harness above), LUMEN’s own p99 was 220.57 ms against an 836.43 ms direct-to-mock p99 on the same noisy host - i.e. the gateway added no measurable tail latency of its own in that run; the recorded p99 is dominated by host contention, not the proxy. |
| 2 | < 25 MB RAM under load | Met. 8.8 MB idle (in-process measurement); ~7.6 MB observed mid-load in the head-to-head run above, consistent with the idle figure - memory is bounded by design (backpressure + bounded channels, usage log drops rather than grows, proven by criterion 5, the 500-concurrent test). |
| 3 | throughput ≥ 95 % of direct | Not cleanly isolable in this run - the direct-to-mock baseline itself was depressed by host contention (1191.7 req/s vs LUMEN’s 2733.5 req/s, i.e. LUMEN measured faster than “direct” because the mockserver JVM saturates a core on its own and the direct target had no backpressure/connection reuse tuning). This is a measurement environment artifact, not a claim that the gateway is faster than a bypass. Re-run on isolated/dedicated hardware for a trustworthy direct-vs-gateway throughput ratio. |
Honest summary: the off-network overhead is measured and is microseconds, comfortably inside the pillar-1 budget. The full loaded head-to-head vs LiteLLM is now a committed, reproducible baseline (pinned versions, one command, recorded result linked above) rather than just a runnable harness; the relative LUMEN-vs-LiteLLM comparison from it is solid, while the absolute and direct-vs-gateway numbers should be treated as this particular (noisy, shared) host’s numbers, not a hardware-independent claim.
Reproducing
# In-process overhead (no Docker needed):
cargo bench -p server --bench gateway_overhead
# Streaming time to first bit, direct vs via-gateway (no Docker needed):
cargo bench -p server --bench stream_ttfb
# Budget admit + settle per request (no Docker needed):
cargo bench -p auth --bench admit_settle
# Idle RAM + binary size:
cargo build --release -p server --bin lumen
./target/release/lumen --config config.example.toml & # then: ps -o rss= -p <pid>
# Full head-to-head vs LiteLLM (Docker + k6, one command): see bench/README.md
bench/run.sh
ADR 001 - Bare package names, lumen_* library names
- Status: accepted
- Date: 2026-07-12
Context
The CLAUDE.md architecture lays out a six-crate workspace under crates/
(core, providers, router, auth, telemetry, server) and documents
commands like cargo run -p server. That -p server selector requires the
Cargo package name to be the bare server.
Naming a package core, however, is hazardous: a library crate literally named
core lands in the extern prelude of any downstream crate and shadows the
standard library’s ::core. This surfaces in the doctest harness, where
::core::fmt, ::core::future, etc. (referenced by expanded std/async_trait
macros) fail to resolve - observed concretely as E0433: cannot find 'fmt' in 'core' while normal builds still passed.
Decision
Keep package names bare (core, providers, router, auth,
telemetry, server) so the documented -p <name> commands work, but give
each library crate an explicit lib name prefixed lumen_:
[package]
name = "core"
[lib]
name = "lumen_core"
path = "src/lib.rs"
Internal dependencies are wired in [workspace.dependencies] with the
lumen-* key mapped to the bare package via package:
lumen-core = { path = "crates/core", package = "core" }
So: cargo run -p server works, imports read use lumen_core::…, and no
crate shadows a std crate.
Consequences
cargo run -p server -- --config …(and-p core, etc.) match the docs.- No std-crate shadowing anywhere, including doctests.
- Slight indirection: the
[workspace.dependencies]key differs from the package name. Documented here so it is not mistaken for an accident. - The published crate names (if we ever publish) would be the bare names; we can revisit and prefix them at publish time without touching source.
ADR 002 - Per-request metadata header for logging & metrics
- Status: accepted (planned)
- Date: 2026-07-12
Context
Operators running a shared gateway need to attribute and slice observability
data by dimensions the gateway cannot infer: which end-user, team, feature,
environment or experiment a call belongs to. Cloudflare AI Gateway solves this
with a cf-aig-metadata request header carrying a small JSON object that is
then attached to each request’s log for filtering and search.
LUMEN needs the same capability, feeding both:
- structured logs (
tracing) and theusage_logrecords, and - Prometheus metrics.
The tension is Prometheus cardinality. Prometheus label values multiply the
number of time series; putting arbitrary client-supplied metadata (e.g. a
user_id) onto metric labels is an unbounded-cardinality footgun that violates
pillar 1 (~15 MB idle, < 1 ms p99) - a few thousand distinct users would blow up
memory and scrape cost. Cloudflare sidesteps this by indexing metadata for
log search, not by turning it into metric dimensions.
We also refuse to make an observability header able to fail a real request: rejecting a chat call because someone sent malformed metadata is user-hostile.
And per the sovereignty pillar, metadata is logged, so it must never be treated as prompt content - it is operator/client labels only.
Decision
Introduce a per-request metadata header, parsed once at the edge into a small typed value carried in request extensions.
-
Header. Canonical
x-lumen-metadata; also acceptcf-aig-metadataas an alias so Cloudflare AI Gateway clients work unchanged. Value is a flat JSON object of string → (string | number | bool). Nested objects/arrays are rejected (dropped, see 4). -
Bounds. At most 16 keys; key ≤ 64 bytes; value ≤ 256 bytes; whole header ≤ 4 KiB. These keep log records and memory bounded.
-
Two sinks, different rules.
- Logs /
usage_log: the full (bounded) object is attached to the request’s structured log fields and stored in ametadatacolumn onusage_logfor later filtering - the Cloudflare-style use case. - Prometheus: ONLY keys named in a config allowlist
(
telemetry.metadata_labels = ["env", "team"], default empty) become metric labels; every other key is logs-only. An allowlisted key absent from a given request gets the label value"". This makes metric cardinality a deliberate, operator-bounded decision - never client-driven.
- Logs /
-
Never fails the request. Missing, malformed, oversized or wrong-typed metadata is dropped with a
debug!/warn!and ametadata_rejected_totalcounter increment; the call proceeds normally. -
Opaque, never inspected. LUMEN does not parse metadata for meaning or PII. Documentation states plainly that metadata is logged and must not carry secrets or prompt content.
Consequences
- Metric cardinality is capped by config, not by traffic - safe by default (empty allowlist = zero new label dimensions).
- Full metadata is still available for rich filtering via
usage_log, matching Cloudflare’s log-search model. - The request path gains only a bounded header parse (no allocation when the header is absent), preserving the latency pillar.
- Implementation lands alongside usage logging (
usage_log.metadatacolumn, the batched writer, and the Prometheus label wiring intelemetry). A thin extractor inserverreads the header into request extensions so chat, embeddings and rerank handlers all share it.
ADR 003 - Token accounting for every request, every capability
- Status: accepted (core promise)
- Date: 2026-07-12
Context
Token counting is a headline reason to run LUMEN: an operator fronting many providers wants one trustworthy answer to “how many tokens did this cost, per model / key / team / capability?” - without trusting each upstream to report it and without leaking prompts to a third party to find out.
The problem is that upstream usage reporting is inconsistent:
- OpenAI/Cohere/Voyage embeddings report input tokens; TEI reports none
(its
/embedis a bare vector array) - so a naive gateway shows0tokens for TEI embeddings. - Streaming chat only carries usage if the client sends
stream_options.include_usage(and some providers still omit it). - Rerank is billed in search units by Cohere but in tokens by Jina/Voyage, and TEI reports neither.
So “count the tokens for all the APIs” cannot mean “pass through whatever the upstream says.” It means the gateway guarantees a count for every call, and is honest about whether that count is measured or estimated.
The hard constraint is pillar 1: < 1 ms added p99, no blocking the runtime. Real tokenizers (BPE) cost real CPU; running one inline on the request path would blow the latency budget.
Decision
Token accounting is a first-class, always-on output of every chat, embeddings and rerank call, produced by a two-tier strategy and surfaced in three places.
Source, in priority order
- Upstream-reported usage - authoritative and free (already in the response
body / final SSE chunk). Always preferred.
estimated = false. - Local estimation fallback - when the upstream omits usage.
estimated = true. Two levels:- default: a cheap, allocation-light heuristic (byte/char-based) that is safe to compute anywhere;
- opt-in: an accurate tokenizer (
tokenizers/tiktoken-style) selected per model in config, run viaspawn_blockingso it never occupies a tokio worker.
Hot-path rule
The request path never runs a heavy tokenizer. Upstream usage is passed through
as-is; when it is missing, accurate estimation happens off the hot path in
the async usage-writer task. The response usage field carries the
upstream value when present, else the cheap heuristic (flagged estimated),
never a blocking BPE pass. Counting must never fail or slow a request.
What is counted, per capability
- Chat:
input_tokens(prompt) andoutput_tokens(completion). - Embeddings:
input_tokens. - Rerank:
search_unitswhen the provider reports them, plus a token count ofquery + documentsfor uniform observability (estimatedwhen derived).
Three surfaces
- Response body - OpenAI-compatible
usage(chat/embeddings) unchanged. - Prometheus - cumulative counters, low fixed cardinality:
lumen_tokens_total{capability, model, provider, direction, estimated}andlumen_rerank_search_units_total{model, provider}. Optional metadata-allowlist labels come from ADR 002 (never client-unbounded). usage_log- per-requesttokens_in,tokens_out,search_units,estimated, alongside cost and metadata for later slicing.
Consequences
- Every call gets a token count regardless of provider - the TEI-reports-nothing gap is closed by estimation, and the count is labelled honestly.
- The latency pillar holds: passthrough is free; BPE estimation is off-hot-path and on the blocking pool.
- The embeddings and rerank paths already surface upstream-reported
usage; this ADR adds the estimation fallback and the Prometheus/usage_log counters plus streaming extraction. Cost counting (§5.4) becomes a consumer of these token counts rather than the thing that defines them. - New error/counter surface: a
tokens_estimated_totalcounter lets operators see how much of their accounting is measured vs estimated.
Addendum (M8 - vision / image input)
Image content parts ({"type":"image_url",...}) do not change the priority
order above; they sharpen what “estimation” means when tier 2 fires.
- Upstream usage stays authoritative and untouched. OpenAI, Anthropic and
Gemini all fold image tokens into their reported
prompt_tokens, so a vision request with upstream-reported usage is exactly as accurate as a text-only one - no special-casing needed. - The local estimation fallback counts text plus a flat per-image estimate.
When the upstream omits usage, the heuristic estimator (
estimate_chat_prompt,crates/core/src/tokens.rs) sumsMessageContent::text()per message (the concatenation oftextparts) plus, for everyimage_urlpart, a flat per-image token constant chosen from the part’sdetailhint:"low"->85tokens (OpenAI’s exact, resolution-independent low-detail cost - no dimensions needed to reproduce it);"high"/"auto"/unset ->765tokens, an approximation of OpenAI’s85 + 170 * tilestile formula for a typical ~1024x1024 image. The response is still flagged"estimated": true, so the client is never told a number is measured when it is not. - A true per-dimension tile count is still deferred - see
docs/backlog.md. OpenAI’s real high-detail formula depends on decoded pixel dimensions, which adata:URI does not carry and this gateway does not extract (the hot-path rule above - never decode/inspect image bytes on the request path - still holds). The flat765-token constant is a documented approximation, not an attempt at per-image precision; it trades some accuracy for closing the “silently counts as 0” gap entirely.
Addendum (issue #10 - rerank token usage shape)
RerankUsage (§ “What is counted, per capability”) carries search_units
and total_tokens as two independent counts, each with its own
*_estimated flag (estimated for search_units, tokens_estimated for
total_tokens) rather than one shared flag - a response can have a real
search_units (Cohere) alongside a derived total_tokens, or the reverse
(Jina/Voyage). The priority order from the “Source” section above applies
per count: Jina and Voyage report usage.total_tokens in their rerank
response, which the gateway passes through unflagged; when a provider omits
it (Cohere, TEI, or Jina/Voyage without usage), the gateway derives
total_tokens from query + documents via the existing byte-heuristic
estimator, flagged "tokens_estimated": true.
Addendum (M9 - multimodal embeddings)
The same priority order applies to image content parts in /v1/embeddings:
upstream usage is trusted when reported (Cohere, Voyage and Jina all fold
image cost into their reported token/usage counts). When the upstream reports
nothing, the local fallback estimates text parts only (image parts contribute
0 tokens) and the response is still flagged estimated: true. This undercounts
image-heavy requests on a no-usage upstream; a per-image token heuristic is a
backlog item (see the ROADMAP M9 note). Media volume itself is accounted
separately (count + decoded bytes) via lumen_media_total /
lumen_media_bytes_total and the usage_log media_count/media_bytes
columns, not through the token counters.
Addendum (opt-in accurate tokenizer)
The “opt-in accurate tokenizer” tier promised above is now implemented, behind a config knob, without disturbing the default heuristic path.
- Config. A new
[tokenizer] mode = "heuristic" | "accurate"selects the local estimation strategy (defaultheuristic). The knob is global, not per-model: accurate mode already keys off the model id internally (below), so a single switch covers a mixed fleet without per-model config. - Encoder. Accurate mode uses
tiktoken-rs(pure Rust, MIT, no OpenSSL) and picks the vocabulary by model prefix:cl100k_baseforgpt-4/gpt-3.5/text-embedding-3,o200k_baseforgpt-4o/o1/o3/gpt-4.1/gpt-5. A model that matches no OpenAI-family prefix (Claude, Mistral, Llama, TEI models, …) keeps the byte heuristic - tiktoken does not describe those vocabularies, so a BPE count would be a false precision. - Hot-path rule, upheld. The heuristic is unchanged: free, inline, used
for the pre-call budget admission estimate on every request AND for the
response envelope. The response
usagefield always carries the cheap heuristic (flaggedestimated) - never a BPE pass, never a wait - exactly as this ADR’s hot-path rule states. The accurate BPE count is computed only when tier 2 fires (an upstream reported no usage) and after the response is handed off: the handler settles the envelope with the heuristic, then defers the accounting close to a spawned background task that recounts with exact BPE on the blocking pool viaspawn_blocking(never on a tokio worker, repo rule 2) and finishes the record. The accurate number therefore surfaces in Prometheus andusage_log(the operator-facing accounting surfaces), not in the response envelope, and the client response is never delayed by refinement. The end-to-end latency histogram andusage_log.latency_msare frozen at response time before the deferral, so they measure the request, not the refinement. Encoders are built once at config load, never lazily on a request. - Scope. Applied to the paths with a post-response settlement: chat (non-streaming), embeddings, and rerank. Rerank tokens remain always gateway-estimated (uniform observability, above) whether or not the upstream billed search units; its refinement follows the same deferred path and is usually a no-op, since real rerank model ids rarely carry an OpenAI tiktoken family prefix. Streaming input counts stay on the heuristic - the stream is forwarded, never buffered (ADR 004), so there is no assembled prompt/response to BPE without violating the passthrough rule.
- Honesty preserved. Accurate local counts are still flagged
estimated = true. A local count, however exact the tokenizer, is an estimate of what the upstream would bill; upstream-reported usage always wins (estimated = false). On any tokenizer failure the deferred close settles with the heuristic numbers, so counting can never fail or reject a request. Consequence to be aware of: when refinement fires, the response envelope (heuristic) and the accounting surfaces (accurate) intentionally differ - the envelope is the hot-path-safe estimate, the accounting surfaces are the billing-grade one.
ADR 004 - Zero-copy SSE streaming vs. the typed chunk trait
- Status: accepted
- Date: 2026-07-12
Context
CLAUDE.md sketches ChatProvider::chat_stream -> BoxStream<ChatChunk>: a
stream of typed chunks. But the streaming spec (§4.2) demands zero-copy
passthrough - when the upstream already speaks OpenAI SSE (OpenAI, Mistral,
Ollama, vLLM), the gateway must forward the SSE frames as Bytes without
deserializing each chunk. Deserializing a ChatChunk and re-serializing it
per frame is exactly the per-token allocation overhead that makes LiteLLM
1.7–4× slower; it violates pillar 1 (< 1 ms added p99).
So the typed stream and the zero-copy requirement pull in opposite directions. Translating providers (Anthropic, Gemini) genuinely need typed chunks - they build OpenAI chunks from a foreign event schema. Passthrough providers must not pay for typing they don’t need.
Decision
Both paths converge on a single server contract: a provider yields the
complete SSE response body as a Bytes stream - framing (data: …\n\n) and
the terminal data: [DONE]\n\n included. The server pipes that byte stream
straight into the HTTP response body; it does not re-frame, and for passthrough
it does not deserialize.
Concretely, add one method to ChatProvider:
#![allow(unused)]
fn main() {
async fn chat_stream_bytes(&self, req, cancel)
-> Result<BoxStream<'static, Result<Bytes, ProviderError>>, ProviderError>;
}
with a default implementation that adapts the typed chat_stream: serialize
each ChatChunk to data: {json}\n\n and append data: [DONE]\n\n. Providers
that translate a foreign schema (Anthropic) inherit the default - correct, if
not zero-copy, which is fine because they must build chunks anyway.
Passthrough providers (OpenAI, Mistral) override chat_stream_bytes:
set stream: true upstream, send, and on success return
reqwest::Response::bytes_stream() mapped to ProviderError - the upstream’s
own bytes, verbatim, [DONE] and all. No serde round-trip on the hot path.
Errors and cancellation
- Failures before the stream (non-2xx status, transport) surface as a normal
Err→ JSON error envelope, exactly like non-streaming. Only a mid-stream failure becomes an SSE error frame. - The server holds the per-request cancel drop-guard inside the body stream,
so a client disconnect drops the body, drops the guard and the underlying
reqwest byte stream, closing the upstream connection promptly (the LiteLLM
#22805 lesson). The initial send is wrapped in
with_canceltoo.
Usage / token accounting (ADR 003)
Pure passthrough does not deserialize, so streaming usage is sniffed off the final frame opportunistically (or estimated) in the token-accounting path - it must never block or re-serialize the passthrough path.
Consequences
- OpenAI/Mistral streaming is true zero-copy: upstream bytes → client bytes, one
bounded copy through the socket, no per-chunk
serde. - Anthropic/Gemini keep the typed path and get correct (non-passthrough) SSE via the default adapter.
- The server’s streaming handler no longer uses axum’s
Ssetype; it writes a rawBytesbody withcontent-type: text/event-stream. SSE heartbeats move to an explicit injected frame rather than axum’sKeepAlive. coregains abytesdependency (already ubiquitous via reqwest) for the trait’s return type.
ADR 005 - Resilience execution model (retries, fallback, circuit breaker, timeouts)
- Status: accepted (amended 2026-07-15: per-provider connect timeout and first-frame-peek streaming retry, see below)
- Date: 2026-07-13
Context
The resilience layer adds retries, multi-provider fallback chains, a per-provider circuit breaker and per-phase timeouts. The overriding constraint is pillar 1 (< 1 ms added p99) and pillar 3 (robustness): none of this may add a database or lock to the request path, and - the explicit lesson (LiteLLM #15526) - a storm of 429s from an upstream must never destabilise the gateway itself. The router crate was previously a pure resolution helper; this change turns it into an execution layer that wraps a provider call with the resilience machinery.
Three design tensions had to be resolved:
- Where the machinery lives, given three capability traits.
ChatProvider,EmbeddingProviderandRerankProviderhave different signatures, so a single concrete executor cannot call all three. Duplicating retry/breaker logic per capability is unacceptable. - Streaming. Retry and fallback are only safe before the first byte reaches the client (spec 6.1/6.2/6.4). Once a frame is forwarded the request is committed.
- Jitter vs. deterministic tests. Backoff needs randomness, but the acceptance criteria assert on elapsed (simulated) time.
Decision
One generic executor, capability-specific chain resolution
The executor is generic over a closure FnMut(link_index) -> Future<Result<T, ProviderError>>. It owns the resilience control flow - breaker gate, retry
loop, fallback across links, total-timeout deadline - and knows nothing about
chat/embed/rerank. Each handler resolves a chain (resolve_*_chain: the
requested model followed by its configured fallbacks, each re-resolved for the
same capability) and supplies a closure that performs the actual typed call
for a given link. The chain the executor sees is metadata only
(provider_name, model_id), which it uses to key the circuit breaker and to
report the model that actually served (x-lumen-model-used).
Fallback chains are validated at boot: every fallback id must exist and serve the same capability as the model it backs (spec 6.2). A runtime resolution miss is therefore not expected, but is treated as a skipped link rather than a panic.
Retry classification lives on ProviderError
ProviderError::is_retryable() (5xx, connect/read timeout, 429, unreachable -
never a 4xx client fault) and is_provider_fault() (does this failure indicate
the provider is unhealthy, i.e. should it count against the breaker) are the
single source of truth, shared by the retry loop and the breaker. A hard
upstream 4xx (bad request) is neither retried nor failed over - a fallback
provider would reject it too - and is returned immediately.
Backoff: pure function + injected randomness
backoff_delay(attempt, policy, retry_after, rand01) is a pure function:
exponential base·2ⁿ capped at max, equal jitter (d = e/2 + e/2·rand01, so
d ∈ [e/2, e]), then floored at Retry-After when the upstream sent a longer
one. Production passes rand01 from a cheap lock-free splitmix64 (no dependency,
no blocking, no Instant::now on the hot path); tests call the pure function
with fixed fractions and assert exact bounds. The equal-jitter floor (e/2)
makes “backoff delays respected” assertions robust regardless of the random
draw, and the Retry-After floor makes criterion 6 (Retry-After: 3 ⇒ ≥ 3 s)
hold unconditionally.
Circuit breaker: in-memory, per (provider, model)
A lock-free-ish CircuitBreaker (a Mutex<small struct> per key, never held
across an .await) transitions Closed → Open (after N consecutive
provider-fault failures) → Half-Open (after the cooldown) → one probe →
Closed/Open. Concurrent requests that find the breaker Half-Open are refused the
probe (treated as Open) so exactly one request probes. State is pushed to a
Prometheus gauge lumen_circuit_state{provider,model} (0 closed / 1 open /
2 half-open) on every transition - the telemetry crate exposes a numeric setter
so router depends on telemetry with no cycle. The breaker map is a
DashMap, entries created on first use; bounded by the (provider × model) count,
which is operator-configured and finite. Never touched off the request path by
a blocking call - health checks read a snapshot.
A logical request records one breaker outcome per link (success, or one failure after that link’s retries are exhausted), so “5 consecutive failures” means five requests, not five retries within one.
Streaming: retry/fallback only at the open phase
execute_stream retries and falls back around opening the upstream byte
stream (send + status check, before any body). Once the stream opens (upstream
returned 2xx and we commit to forwarding), the existing to_event_stream guards
(ADR 004: LM-3010 missing terminator, LM-3011 first-token, heartbeat) own the
rest and never retry. This satisfies “retry only if no chunk emitted”: an open
failure means nothing was forwarded, a post-open failure becomes a clean SSE
error frame. (A 2xx-then-immediate-error is deliberately treated as committed -
not retried - since the upstream accepted the request.)
One consequence, recorded explicitly: the circuit breaker for a streaming call
only ever sees the open phase. on_success fires as soon as the byte stream
opens, so a provider that opens cleanly but then dies mid-stream every time
(LM-3010, handled by the frame guards and never surfaced back to the breaker)
will not trip its circuit. This is the accepted trade-off of the open-phase
boundary; the frame guards still give the client a clean terminal error each
time.
Half-open cannot wedge. A half-open probe is normally resolved by
on_success/on_failure, but a probe whose result never returns (client
disconnect, the total-timeout firing mid-probe, or a non-provider-fault error
that does neither) must not pin the breaker shut. The breaker records when a
probe was admitted and auto-rearms: a probe still outstanding after a full
cooldown is presumed lost and a fresh one is admitted. The single-probe
guarantee therefore holds within a cooldown window, and recovery is always
self-healing.
Timeouts and new error codes
Three timeouts, global defaults with per-model overrides:
- connect (default 5 s) - a
reqwest::Clientsetting. Originally global only (per-provider connect would require one client per provider and lose connection pooling; deferred). Superseded by the 2026-07-15 amendment below: a provider may now override it withconnect_timeout_ms, at the cost of its own (unpooled) client. A connect timeout is distinguished from a read timeout:ProviderError::ConnectTimeout→LM-3012(504). - first_token (default 30 s, per-model override) - reuses the streaming path;
LM-3011(504). For non-streaming it bounds the whole call; for streaming, the time to the first frame. - total (default 600 s, per-model override) - an absolute deadline threaded
through the executor bounding all retries and fallbacks together; exceeding
it yields
LM-3013(504).
Circuit open with no fallback left is LM-3020 (503) carrying Retry-After
(the cooldown remainder). LM-3004 (no healthy upstream) already existed and is
kept for “all fallbacks exhausted”.
Health checks: optional, off the request path
A background task (default off) probes each provider that has a configured
base_url (self-hosted TEI/Ollama, or any explicit override) with a short GET,
storing Up/Down + latency in memory and a lumen_provider_up{provider}
gauge. Providers relying on a built-in vendor URL report unknown - the gateway
never hardcodes vendor endpoints. Results are exposed at /health/providers
for observability; the gateway’s own /health stays completely independent
of provider health (criterion 5) and does no I/O.
Consequences
- Handlers change from
route.provider.chat(...)toexecute_unary(chain, ..., |i| chain[i].provider.chat(...)); the resilience policy is uniform across all three capabilities and both streaming modes. routergains a dependency ontelemetry(gauge) and ontokio/futures(it is now async). No dependency cycle.- The circuit-breaker map and health results are process-wide in-memory state in
AppState; nothing resilience-related touches SQLite on the request path. usage_loggains amodel_usedcolumn (migration 0002) so a fallback is observable after the fact, mirroring thex-lumen-model-usedheader.- Per-provider connect timeouts and true first-frame-peek streaming retries are
explicitly out of scope here and recorded in
docs/backlog.md. (Per-provider connect timeouts were subsequently implemented; see the amendment below.)
Amendment (2026-07-15): per-provider connect timeout
The original decision left connect global-only because a per-provider connect
timeout is a reqwest::Client setting and one pooled client is shared across
all providers, so overriding it per provider would mean one client per provider
and the loss of cross-provider connection pooling. Issue #24 asked for it anyway
(an upstream that is reliably reachable can afford a much tighter connect
deadline than a flaky one; a distant self-hosted box may need a looser one), so
the deferral is superseded.
Chosen design. The shared, pooled client remains the default and carries the
global resilience.connect_timeout_ms. A provider that sets the new optional
connect_timeout_ms (alongside the existing per-provider first_token_timeout_ms
and total_timeout_ms) is given its own reqwest::Client, built once at
registry construction, with that connect timeout and the same overall backstop
as the shared client. Every provider that does not override keeps sharing the
one pooled client, so pooling is preserved for the common case and only an
explicitly-overriding provider pays for it.
Trade-off (documented, accepted). An overriding provider no longer shares the process-wide connection pool: its connections are pooled only within its own dedicated client. This is a per-provider, opt-in cost. Nothing else about the provider changes (same overall cap, same executor timeouts, same error codes).
Where it lives. ProviderSpec gains connect_timeout_ms: Option<u64>;
Registry::build/reload take the overall backstop and build the dedicated
clients in build_inner. Because the registry rebuilds all clients from the new
specs on every hot reload, changing (adding, editing or removing) a provider’s
connect_timeout_ms takes effect on SIGHUP/file-change reload with no restart,
exactly like the other two per-provider timeout overrides. Config validation
rejects a connect_timeout_ms of 0, matching the other overrides.
Amendment - 2026-07-15: first-frame-peek streaming retry (issue #7)
Supersedes the “2xx-then-immediate-error is deliberately treated as committed”
carve-out above (the parenthetical in Streaming: retry/fallback only at the
open phase) and the matching docs/backlog.md deferral. The rest of the
original decision stands unchanged.
Decision
The commitment point for a streaming response moves from the open (2xx + headers) to the first content frame. After the open succeeds, the streaming closure PEEKS the first upstream frame before committing:
- first item is a content frame (
Ok) - commit: the peeked frame is re-attached ahead of the untouched remainder (a singleBytes, moved not copied) and the reconstructed stream is handed to the existingto_event_streamguards. From here nothing retries (mid-stream errors still become a terminal SSE error frame - LM-3010/LM-3003 - exactly as before); - first item is an error (
Err), or the stream ends before any frame (None) - a pre-commit failure: the closure returnsErr, so the executor retries, falls over to the next link, and charges the circuit breaker identically to an open failure. ANoneis surfaced as the newProviderError::EmptyStream(retryable, provider-fault, mapped to LM-3010).
Boundaries and invariants preserved
- Zero-copy / bounded buffering (pillar 1, ADR 004). The peek buffers at most one frame, never the stream. The committed body is byte-identical to the upstream.
- Cancellation. The peek races the request
CancellationToken; a client disconnect during the peek window returnsProviderError::Cancelledand drops the stream, aborting the upstream.Cancelledis neither retryable nor a provider fault, so no fallback is attempted and the breaker is untouched. - Time bound. The peek runs inside the closure the executor already wraps in
the per-attempt
first_tokentimeout, so a silent upstream (headers, then no bytes) tripsFirstTokenTimeoutand fails over rather than hanging. - Still non-retryable (unchanged): everything post-commit. Once a content
frame is forwarded, a later mid-stream error, a missing
[DONE](LM-3010) or a first-token gap on a subsequent frame is a terminal SSE error, never a retry.
Consequence for the breaker note above
The recorded trade-off (“the breaker only ever sees the open phase”) is now narrower: a provider that opens 200 then fails on its first frame does now count against its breaker. Only failures after the first committed content frame remain invisible to the breaker (the frame guards still give the client a clean terminal error each time).
The peek lives in crates/router/src/peek.rs (generic over the frame type,
unit-tested for commit / error / empty / cancel / timeout); wiremock acceptance
tests are in crates/server/tests/resilience.rs.
ADR 006 - A dedicated error code for client-initiated cancellation
- Status: accepted
- Date: 2026-07-15
Context
ProviderError::Cancelled is produced when the per-request CancellationToken
fires - normally because the client disconnected mid-request (ADR 004, M6).
Since M1 this mapped to GatewayError::Internal("request cancelled"): HTTP
500, type: internal, LM-5001.
That mapping was flagged in docs/backlog.md at M1 (“revisit in M4”) and
tracked as GitHub issue #11: a client hanging up is not a gateway
malfunction, but reporting it as LM-5001/500 makes it indistinguishable
from one in every place that matters operationally - the
lumen_http_request_duration_seconds{status} / lumen_request_duration_seconds{status}
Prometheus histograms, and any alert rule built on the 5xx or status="500"
label. A busy gateway with many client cancels (slow clients, mobile
networks, users navigating away mid-stream) would then look, to an operator,
identical to a gateway that is actually failing.
The existing taxonomy (CLAUDE.md rule 8, docs/errors.md) is pinned to
exactly three situations - client error / upstream error / internal error -
each with its own type. A client cancel does not fit any of the three:
it is not a rejected request (the request was valid), not an upstream fault
(the provider never got a chance to fail), and not a gateway malfunction (the
gateway did exactly what it should: stop work for a client that left).
Silently picking one of the three to avoid extending the enum would keep
reproducing the same misclassification this issue exists to fix.
Decision
A fourth ErrorType, LM-6xxx as its own code prefix
Add GatewayError::ClientCancelled (LM-6001) and ErrorType::ClientCancelled
(serialized "client_cancelled"), alongside - not replacing - the existing
three-way split. docs/errors.md documents it as its own section, and the
code-prefix table gains 6xxx for client-cancellation. This is an additive
change to the public envelope schema (type gains a fourth possible value);
existing clients that switch on the three known values are unaffected as long
as they have a default case, which the taxonomy already requires them to
handle (new codes are added within existing prefixes routinely).
ProviderError::Cancelled now maps to GatewayError::ClientCancelled instead
of GatewayError::Internal in GatewayError::from_provider.
HTTP status: 499
499 is the conventional “client closed request” status popularised by
nginx - not in the IANA registry, but widely recognised in logs/dashboards
and, critically, not a 5xx. The client has normally already disconnected
by the time this status would be written, so it is never actually read by
anyone; its only audience is server logs and the status label on the
latency histograms. Any other 4xx would misleadingly imply the client’s
request was at fault (it wasn’t - the request was fine, the client just left
before it finished).
Telemetry: an explicit "499" label, not the "4xx" catch-all
crates/telemetry/src/latency.rs::status_str already degrades uncommon
statuses to a coarse class ("4xx", "5xx", …) to keep Prometheus
cardinality bounded. 499 gets an explicit arm instead, ahead of that
catch-all, for the same reason LM-6001 gets its own code rather than being
folded into an existing one: an operator dashboarding cancellation volume
(e.g. to catch a client-side bug causing excessive aborts) needs to see it
distinctly from ordinary 4xx client errors, not just confirm it isn’t a
5xx.
The stream-accounting safety net settles disconnects as 499
StreamAccounting (crates/server/src/accounting.rs) closes the per-request
accounting record when a chat stream ends. Clean ends and in-band error
frames settle explicitly with their real status; the Drop impl is the
safety net for a body dropped before any terminal event - which is precisely
a mid-stream client disconnect (or, rarely, server shutdown). That net used
to hardcode 200, silently recording the most common real-world cancel as a
success. It now settles at 499, and the stream wrapper settles at 200 as
soon as the [DONE] terminator is observed so a client that disconnects
after a clean end is still recorded as a 200.
Consequences
Precisely which cancellation paths this covers:
- Mid-stream client disconnect (the common case): the SSE body is
dropped before its terminal event;
StreamAccounting’s drop net now settlesusage_log.statusand thelumen_request_duration_secondssample at499instead of a fake200. These were never inflating 5xx alerts before - worse, they were invisible, recorded as successes. The win here is honest classification and a countable cancellation signal. - Cooperative in-band cancellation while the stream is polled: the
provider byte stream’s
select!on theCancellationToken(crates/providers/src/http.rs) yieldsProviderError::Cancelled, which the stream wrapper maps throughGatewayError::from_providerinto a terminal SSE error frame. That frame now carriesLM-6001/client_cancelledand settles the sample at499- previouslyLM-5001/internal/500. This was the one path that genuinely inflatedstatus="500"samples, and alert rules built onstatus=~"5.."stop firing on it with no rule change. - Not covered - non-streaming client disconnect: dropping the connection drops the whole handler and middleware future, so no status sample is recorded at all, before or after this change (the latency middleware’s own documentation notes it only observes completed requests). There is nothing to relabel on this path: it never polluted any metric, and it still produces no sample. Making it observable would need a disconnect-aware middleware, out of scope here.
- Server shutdown dropping in-flight streams is indistinguishable from a client disconnect at this layer and is also recorded as 499. Acceptable: it is rare, and “the stream was cut before its end through no fault of the gateway’s request handling” is the semantic 499 carries here.
Other consequences:
- The public error envelope’s
typefield gains a fourth possible value,client_cancelled. This is additive;docs/errors.mdis updated as the source of truth. crates/core/src/error.rs’s three-way taxonomy doc comment (CLAUDE.md rule 8) now notes this fourth, orthogonal situation rather than silently breaking the “always exactly three” framing.
ADR 007 - Rate-limit and usage-log accounting refinements
- Status: accepted
- Date: 2026-07-15
Context
M5 shipped admission control (RPM/TPM quotas, hard budgets) and the usage log
with four documented behaviours flagged in docs/backlog.md for revisiting
(issue #26):
- TPM debited the pre-call estimate and never adjusted it. A chat request
with
max_tokens = 2048but 40 real output tokens permanently burned 2048 tokens of the per-minute window, starving later requests. - A request rejected by a later admission step still counted toward the
earlier quotas.
admitbumps RPM, then TPM, then the budget CAS; when the budget refused (402), the RPM and TPM bumps it had already made were never unwound, so a refused request consumed quota it never used. - The usage log recorded successful requests only. A refusal at admission
(402/429) errored out of
Accounting::beginbefore anyUsageRecordwas built, so per-key rejection analytics were impossible. - Metadata values were stringified in
usage_log.metadata({"batch":"42"}), losing the original JSON type and blocking numeric filtering.
The budget already had the right shape: reserve the estimate, then
Reservation::settle to the real cost (over-reservation released, shortfall
charged, real cost wins even past the limit). These refinements bring TPM and
the usage log up to the same standard, without violating the hot-path rules
(no blocking, no synchronous DB write on the request path).
Decision
1. TPM is settled to real usage, like the budget
Reservation now carries the tokens it debited to the TPM window (and the
minute it debited them in). settle(actual_cost_micro, actual_tokens) adjusts
the TPM window by actual_tokens - estimate, mirroring the budget: a smaller
real count frees the window, a larger one overshoots and the next request is
refused. The adjustment is a CAS loop (adjust_window) and is a no-op once the
window has rolled to a new minute (the debited slot has already expired).
Asymmetry with the budget on drop. Dropping a reservation unsettled (upstream
failure / cancellation before settle) refunds the budget (no money was
spent) but keeps the TPM debit. TPM is a rate limiter: a request that hit
the gateway counts against the per-minute rate even if the upstream call then
failed. Only a successful settle, which knows the real token count, adjusts
TPM. This preserves the established M5 principle (“refused/failed requests still
count toward the rate”) while fixing the starvation caused by over-estimates on
successful calls, which is the common case.
2. Quota bumps are unwound when a later step rejects
Within a single admit, if the TPM step refuses after RPM was bumped, the RPM
bump is rolled back; if the budget step refuses after RPM and TPM were bumped,
both are rolled back. A request refused inside admission therefore consumes no
quota. (A request refused at its own step - e.g. RPM over the cap - never
bumped that window in the first place, since bump_window checks before
incrementing.) Rollback uses the same adjust_window CAS helper and the same
minute guard.
3. Rejected requests produce a status-only usage row
When admission refuses, Accounting::begin enqueues a UsageRecord with the
rejection status (402/429), zero tokens, zero cost, and any request metadata,
then returns the error. It goes through the same bounded-mpsc UsageLogger
as successful requests (try_send; a full channel drops and counts the row) -
never a synchronous DB write on the request path (the M5 hot-path rule stands).
Zero tokens is the honest count: a rejected request reached no provider and
produced nothing. The status column is what carries the rejection for
analytics. Scope note: 401 (unknown/invalid key) is not logged - it is
refused in the auth middleware before begin, where there is no key to
attribute the row to. Upstream failures (5xx) after admission are already
logged by the normal finish / finalize_with_status paths.
4. Metadata keeps its JSON value types
RequestMetadata stores Vec<(String, serde_json::Value)> (validated to
string/number/bool) instead of pre-stringified strings. to_json - the source
of the usage_log.metadata TEXT column - now emits typed JSON
({"batch":42,"canary":true}), so SQLite json_extract can filter
numerically. Prometheus labels, which are always strings, stringify on the way
out (label_values returns Cow<str>, borrowing the string case and only
allocating for numbers/bools). The column stays TEXT; only its contents gained
type fidelity, so no migration is needed.
Consequences
- The TPM window now tracks real usage for the common (successful) case, so
large
max_tokensreservations no longer starve a key for a full minute. - Rejection analytics are possible per key (
SELECT status, COUNT(*) FROM usage_log ... WHERE status IN (402, 429)), at the cost of extra usage-log volume during quota storms - bounded by the same channel capacity and drop counter as everything else. Reservation::settlegained a parameter; all call sites pass the real total token count (tokens_in + tokens_out).usage_log.metadatamay now contain non-string JSON values; consumers that assumed all-strings should read it as typed JSON.
ADR 008 - Hot reload for auth knobs and DB provider-key rotation
- Status: accepted
- Date: 2026-07-15
Context
Hot reload (ADR-adjacent, M7 §7.3) already re-validated the config on SIGHUP or a
config-file change and atomically swapped the routing table, price table and
resilience policy (circuit-breaker state preserved). Two surfaces were still
boot-time only and were flagged as debt (issue #20, docs/backlog.md M5/M7):
- The
[auth]operational knobs (budget-flush cadence, usage-log retention, usage-writer channel sizing) were read once and baked into background tasks. - A provider key stored in the encrypted DB via
PUT /admin/provider-keysonly took effect at the next restart: the DB-key snapshot was captured once at boot and merely re-applied unchanged on every reload, so a rotation was invisible until a restart.
The overriding constraints are the repo pillars: DB stays off the request path (pillar 3), no secret leaks (STRICT rule 5), and reload must remain atomic and non-disruptive to in-flight requests. Three questions had to be resolved that the specs do not cover.
Decision
1. Server bind address stays restart-only (explicitly out of scope)
The issue lists the bind address among “read once at boot”, but rebinding a live
TcpListener under load is high-risk (dropped connections, port races, partial
failure with no clean rollback) for negligible benefit over a rolling restart.
We deliberately do not hot-rebind host/port. This is documented as a
permanent restart-only limitation in the reload module docs and the backlog,
rather than left as an implied gap.
2. DB provider keys are re-read on every reload, in the reload task
Instead of a frozen boot snapshot, ReloadTargets now carries an optional
ProviderKeySource (an owned KeyStore clone + its own MasterKey handle + the
configured provider names) and the backfill lives in an
Arc<ArcSwap<HashMap<..>>>. Each reload runs reload_once, which:
- async, in the reload task (never the request path): re-reads and decrypts
every configured provider key from the store into a fresh map and stores it
into the
ArcSwap. A DB/decryption error is logged and the previous snapshot is kept, so a sick DB can never strip a working key. - on a blocking thread: runs
apply_reload, which reads the refreshed snapshot and merges it into env-keyless specs before the (fallible) registry rebuild, then swaps the registryArcSwapatomically.
Environment variables keep precedence (a spec with a resolved env key is left
untouched), so rotation via this route only affects env-keyless providers, which
matches the existing “DB back-fills providers whose api_key_env is unset”
model. The registry is rebuilt wholesale (the same path SIGHUP already used); a
key rotation does not change routing, only the api_key carried by rebuilt
providers.
3. PUT /admin/provider-keys triggers a reload via a shared Notify
To make a rotation apply without waiting for a SIGHUP or file touch, the admin
handler pings a shared tokio::sync::Notify after the DB write completes; the
reloader task selects on it as a third wake source alongside SIGHUP and the file
watcher. The trigger is exposed on AppState only when the reloader is actually
armed, so the admin API never claims a rotation was applied when no reloader is
running. The DB write is awaited before the notify, so the reload always observes
the new key. Coalescing is free: Notify collapses a burst into one wake.
4. Auth knobs are a live atomic cell, not task-local constants
The runtime-safe knobs (flush_interval_ms, retention_days) live in a shared
AuthKnobs (two atomics). The budget-flush task became a sleep-loop that reads
the interval each cycle (a tokio::time::Interval cannot have its period changed
in place), and the retention-purge task reads the window each tick. A reload
overwrites the atomics, so both tasks pick up new values on their next tick with
no restart. .max(1) guards a reload that disables auth (knob -> 0).
The bounded usage-log channel knobs (usage_channel_capacity, usage_batch_max,
usage_flush_ms) are not made reloadable: the channel capacity is structural
(fixed when the mpsc is created and the UsageLogger clones are handed to the
app), so changing it means re-plumbing the writer - a restart is the honest
boundary. auth.enabled and auth.db_path are likewise structural (they decide
whether the whole stack and its DB connection exist).
Consequences
- Rotating a provider key is now a live operation:
PUT /admin/provider-keysthen requests authenticate with the new key within one reload, no restart. - The reload’s DB read is bounded to the configured provider set and runs only in the reload task, honouring “DB off the request path”.
- Keys are never logged:
ProviderKeySourceholds a redactedMasterKey(zeroized on drop) and the backfill map carries raw keys only in memory, merged intoProviderSpecwhoseDebugalready redactsapi_key. - The restart-only surface (bind address,
enabled,db_path, channel sizing) is now explicitly documented rather than an accident of implementation.
ADR 009 - Shared parent budgets (budget groups)
- Status: accepted
- Date: 2026-07-22
Context
A virtual key is today the only budget boundary: budget_max lives on the key
and is enforced in memory by a CAS reservation (M5, refined by ADR 007). That
model cannot express a common billing shape: one prepaid pool consumed by
several keys. The concrete motivator is a control plane that sells credits per
customer while issuing one key per project of that customer; today it must
chunk-allocate budget_max across the project keys and rebalance them from
outside, which is racy and leaves credits stranded on idle keys.
Constraints inherited from the pillars and prior ADRs:
- Admission stays in per-process memory, before any upstream call; the database is never on the request path (M5).
- A request refused at admission consumes nothing: quota bumps and budget reservations made by earlier admission steps are unwound (ADR 007).
- Admin mutations apply to the DB and the in-memory state together, with no restart; hot reload re-reads DB state and never strips working state on a read error (ADR 008).
- v1 is single-instance with auth enabled; nothing here changes that
(
docs/operations/deployment.md, Scaling and high availability).
Decision
Introduce budget groups: a named budget pool that any number of virtual keys can belong to. Admission checks the key’s own budget (when set) AND the group’s budget (when the key belongs to one); spend settles against both.
1. Model
A group is {id, name, budget_max, budget_spent, created_at, deleted_at}.
budget_max = NULL means unlimited (a pure attribution container). Groups
carry budget only in this slice: no group RPM/TPM, no disabled flag, no
expiry (future work, below). A key references at most one group via a nullable
group_id; membership is admin-managed, never config-file state (groups and
keys are DB entities, unlike providers).
budget_spent on the group is its own accumulator, flushed like key spend; it
is never recomputed from member keys. Spend that member keys accrued before
joining (or after leaving) the group is not retroactively moved.
2. Admission and settlement
admit order becomes: RPM bump, TPM bump, key budget reserve, group budget
reserve. A refusal at any step unwinds every earlier step (ADR 007 rule),
so a request refused by the group consumes no key quota and no key budget.
The Reservation additionally holds the group entry and the amount reserved
against it, captured at admission:
settle(actual_cost, actual_tokens)applies the cost delta to the key AND the group (the TPM adjustment is key-only; groups have no TPM).- Dropping unsettled refunds both budget reservations; the TPM debit stays, per ADR 007.
- A key moved to another group mid-flight settles against the group captured at admission - spend is attributed to the pool that admitted it.
Both refusals reuse LM-4001 (402): the semantic is unchanged, “a hard
budget you are subject to is exhausted”. The error message distinguishes
the scope (“budget exceeded for this key” vs “budget exceeded for this key’s
group”) via a scope field on GatewayError::BudgetExceeded; there is no
probing concern because a caller can only observe budgets it is billed
against. The key-scope message is byte-identical to today’s.
3. In-memory state
AuthState gains groups: DashMap<String, Arc<GroupEntry>>. GroupEntry is
the group analogue of KeyEntry’s budget half: budget_max_micro,
spent_micro, dirty, same micro-USD atomics, same CAS reservation loop.
KeyEntry holds group: ArcSwapOption<GroupEntry> (lock-free load on the
hot path; arc-swap joins the auth crate from the existing workspace
dependency). The pointer is resolved by AuthState (boot load, upsert,
apply), never by KeyEntry::from_record. A group_id that cannot be
resolved in memory (only reachable through out-of-band DB edits) leaves the
key without live group enforcement and logs a warning - fail-open for that
key rather than refusing all its traffic.
4. Flush, shutdown, reload, crash
- The periodic flusher drains dirty groups exactly like dirty keys
(
drain_dirty_groupsalongsidedrain_dirty) intopersist_group_budgets; the shutdown drain does the same final flush. - Hot reload re-reads groups from the DB before re-reading keys (keys resolve group pointers), upsert-only, limits re-applied, in-memory spend preserved, DB errors keep the current tables (ADR 008 semantics).
- Crash recovery is identical to keys: enforcement lives in memory ahead of
the flush, so a running process never overruns; a crash loses at most
flush_interval_msof accounting per pool, and after restart budgets reload from the last persisted state.
5. Storage and usage attribution
Migration 0007_budget_groups.sql:
CREATE TABLE budget_groups (...)as in §1;ALTER TABLE virtual_keys ADD COLUMN group_id TEXT(nullable; no SQLite FK, consistent with the schema’s existing app-level referential integrity);ALTER TABLE usage_log ADD COLUMN group_id TEXTplus an index.
Every usage row (success AND admission refusal, per ADR 007) is stamped with
the key’s group id captured at accounting begin, so per-pool reporting works
even for refused traffic. GET /admin/usage gains group_by=group_id and a
group_id filter, both riding the existing closed-set plumbing.
6. Admin surface
POST /admin/groups{name, budget_max?}- create (201, the record; no secret exists for a group).GET /admin/groups(?include_deleted=truefor tombstones) - list.PATCH /admin/groups/{id}{name?, budget_max?}- adjust; spend preserved.DELETE /admin/groups/{id}- soft delete, refused (400, LM-1001) while the group still has active member keys; the tombstone keepsusage_logattribution, mirroring key deletion, and its final spend is flushed on removal from memory.POST /admin/keysacceptsgroup_id;PATCH /admin/keys/{id}acceptsgroup_idas a tri-state field (absent = unchanged,null= leave the group, string = join) - the one deliberate divergence from the “patches cannot clear to NULL” rule, because leaving a group must not require re-minting the key. Agroup_idnaming an unknown or deleted group is refused (400, LM-1001) before any write.lumen keys creategains--group-id(validated against the DB); alumen groupsoffline subcommand is deferred - groups have no bootstrap chicken-and-egg problem because/admin/groupsneeds only the master key.
Every mutation applies to the DB and the in-memory maps together, effective on the next request, no restart - the ADR 008 contract.
Consequences
- The prepaid-credits control-plane pattern collapses to: one group per customer, one key per project, top up the group. No chunk allocation, no rebalancing, no stranded credits.
- The hot path gains one
ArcSwapOptionload and, for grouped keys, one more CAS loop per request - same order of cost as the existing key reservation, still allocation-free, still no locks held across await points. - Two pools can now refuse a request; operators must read the LM-4001 message
(or the usage log’s 402 rows grouped by
group_id) to see which. VirtualKeyRecordgrowsgroup_id, so every enumerated column list (load_auth_entries,list_keys,find_by_hash,fetch_key, the integrity dump) changes in lockstep; the admin key responses expose it.- Known admin-plane race, accepted for v1.
delete_group’s member count and tombstone are separate statements, as are the membership validation and write in key create/patch, so a key racing into a group being deleted can orphan onto the tombstone. The consequence is bounded and fail-open by design: liveArcholders keep enforcing against the detached pool, and after a reload the danglinggroup_idresolves to no-pool-enforcement with a warning. Admin mutations are expected to be serialized by the control plane; wrapping count+tombstone (and validate+write) inBEGIN IMMEDIATEtransactions is the cheap hardening if that assumption ever breaks. - Not in this slice (recorded in
docs/backlog.md): group RPM/TPM, a groupdisabledflag, group expiry, nested groups, alumen_group_budget_remaininggauge, and alumen groupsCLI.
Amendment (2026-07-22): atomic grant routes
A prepaid-credits control plane tops up budgets concurrently; a
read-modify-write PATCH budget_max can lose one of two racing top-ups.
POST /admin/keys/{id}/grant and POST /admin/groups/{id}/grant take
{"amount": <USD>} and raise the cap as an atomic increment on BOTH sides:
budget_max = budget_max + ? inside SQLite, and a fetch_add on the live
entry - neither side ever re-reads-then-writes, so concurrent grants all
land. Spend and quota windows are untouched; the effect is immediate, no
reload. Guardrails, all 400 LM-1001: the amount must be positive, finite
and at most 1e12 USD (serde_json already rejects overflowing literals like
1e999 at parse time; the finite check is belt-and-braces, and the upper
bound keeps repeated grants from summing the DB float toward +Inf, which
would reload as an unlimited cap), an unknown or deleted id is refused,
and granting to a capless (budget_max NULL) key or group is refused
rather than silently meaning nothing - set a cap with a PATCH first. In
memory the UNLIMITED sentinel is protected in both directions: a capless
entry stays capless, and a capped entry saturates below the sentinel so a
grant can never accidentally mint an unlimited budget.
Consistency contract: the database is authoritative. The grant is two
independent atomic increments (DB, then memory), so three interleavings
can leave the live cap diverging from the DB by at most one grant amount
until the next reload or restart heals it: a hot reload or a PATCH
(both store caps absolutely) racing the two steps in either direction,
and a client disconnect between the DB write and the memory increment.
Grants are also not idempotent: a timed-out grant may have landed - verify
with a GET before retrying (an Idempotency-Key header is in the
backlog).
Amendment (2026-10-07, Lab QA): an unknown or deleted id on the grant routes
(and on every other key and group route) is now 404 LM-1003 naming the
id, no longer 400 LM-1001; the amount and capless-target refusals stay
400 LM-1001.
ADR 010 - Operator console and fleet control
- Status: accepted
- Date: 2026-08-19
Context
LUMEN is administered by curl against /admin, guarded by one shared master
key. Running a gateway per region (for client round-trip latency) multiplies
that credential by the number of deployments and leaves no aggregate view of
spend, usage or provider health.
We want a web console that manages several gateway deployments: key and budget administration, usage and cost dashboards, live operational state, and provider configuration.
Constraints inherited from the pillars and prior ADRs:
- Admission and spend stay in per-process memory, before any upstream call; the database is never on the request path (M5, ADR 007).
- Config is a file, validated then swapped atomically; an invalid reload keeps the previous configuration (ADR 008).
- The gateway does not terminate TLS and delegates it to a reverse proxy
(
docs/operations/deployment.md). - v1 is single-instance with auth enabled.
- Prompts are never persisted;
usage_loghas no request or response columns by construction.
Decision
A console shipped as a separate deployment, not embedded in the gateway binary, managing N independently configured gateways.
1. Tenancy: a project is pinned to one gateway
A project is bound to exactly one home gateway. Its virtual key lives on that gateway and its budget is enforced there, in memory, unchanged.
This is the load-bearing decision. It means there is no cross-region key, no
budget spanning gateways, and therefore no shared auth state: no Postgres
backend for virtual_keys, no Redis for distributed rate limiting, and no
allocation of budget slices across regions. Every console mutation targets
exactly one gateway, so nothing in the design is a distributed transaction.
The cost is that moving a project between regions is a manual migration (mint a key on the destination, retire the old one, accept that usage history splits across two gateways). This is documented as an operational procedure rather than built as a feature.
2. Control direction: push over a private network
The console calls each gateway’s existing /admin API directly. No polling
agent is added to the gateway, and the admin API stays the one control
surface.
Push requires the console to reach the gateways, so the console runs on operator-controlled infrastructure with private network reachability (WireGuard or Tailscale). Platforms whose functions have no persistent network identity are unsuitable, because they would force gateway admin ports onto the public internet behind a credential that can mint keys.
3. Source of truth
The gateway remains authoritative for keys, budgets and live usage. The
console’s database is a registry plus a cache, and stores no spend or budget
column. Disagreement is surfaced as drift, never auto-healed: the console
diffs GET /admin/keys against its own bindings and reports differences,
because auto-reconciliation would delete keys created deliberately through
the CLI.
There is one deliberate inversion. The gateway sweeps usage_log rows past
its retention window, so beyond that window the console is the only
remaining copy. Console retention must exceed gateway retention.
4. Human identity is delegated
The console implements no login, no password storage and no session
minting. It sits behind an OIDC reverse proxy and trusts a signed identity
assertion, exactly as the gateway delegates TLS to a proxy. Two roles:
admin mutates, viewer reads.
5. Two new admin endpoints
GET / PUT /admin/config. Config stays file-owned: the file remains
diffable, GitOps keeps working, and a gateway restarted without the console
comes up identically. PUT validates the submitted TOML and builds a
candidate registry from it before writing, because writing an invalid file
first would break the next SIGHUP or restart with no visible cause. The
write is atomic (temp file, fsync, rename) and guarded by If-Match against
a content hash returned by GET, so concurrent operators cannot silently
lose an edit. The previous file is kept for revert.
This endpoint can repoint a provider’s base_url and thereby redirect
customer traffic. It is the highest-privilege operation in the system.
Consistent with decision 4, the gateway itself has no acting identity to
log - that lives in the console’s own audit_log, keyed to the signed
identity the reverse proxy asserted. What the gateway records on its own
side is content-level, not identity-level: a successful apply logs the old
and new config content hashes at info, and a rejected apply (a stale
If-Match, invalid TOML, or a config the registry cannot build) logs the
full rejection detail at warn. Hashes only, never file content or secrets.
GET /admin/usage/export. Cursor-paginated raw usage_log rows.
GET /admin/usage aggregates over one dimension at a time, so building a
dashboard cube from it needs one call per dimension per window per gateway
and still cannot answer cross-dimensional questions. Raw export lets the
console build its cube once. Sovereignty is unaffected: usage_log holds no
prompt or response content, and the only caller-supplied field is the
ADR 002 metadata column.
Consequences
- The July fleet-state blockers (shared Postgres for auth, Redis rate limiting, budget allocation across regions) are not required for this work. They return only if decision 1 is reversed.
lumen_usage_log_dropped_totalbecomes user-visible. The bounded usage channel drops entries under pressure by design, which is correct for a gateway and wrong for a dashboard that would otherwise under-report silently; the console records the counter per collection window and warns for affected periods.- A self-hoster now needs an OIDC provider before seeing a dashboard. This is mitigated with a documented forward-auth recipe and a loopback-only development bypass, not by weakening the model.
CLAUDE.mdlists “Web UI” under what v1 does not do. This ADR supersedes that line; billing remains out of scope.
Alternatives considered
Console embedded in the gateway binary. Preserves the single-binary pillar and needs no network trust model, but cannot manage more than the gateway it ships inside, which defeats the multi-region goal.
Pull agent. Gateways poll the console for desired state. Removes the inbound admin surface and is NAT-friendly, but adds a substantial new subsystem to a gateway that deliberately does one thing, and reduces every console mutation to “queued” rather than confirmed.
Config owned by the database. Would make the console the source of truth for providers, but breaks GitOps, makes a console outage a configuration outage, and diverges from the file-and-validate-then-swap model in ADR 008.
Global keys with shared state. Real cross-region budgets, at the cost of a Postgres dependency on the auth path plus Redis for enforcement. Rejected as unnecessary once a project is pinned to one gateway.
ADR 011 - Outbound webhooks for budget events
- Status: accepted
- Date: 2026-08-25 (amended 2026-08-26: admin control surface)
- Tracking issue: #146
Context
A billing control plane integrating with LUMEN already has two of the three legs a budget loop needs:
- Control (push, backend to gateway):
POST /admin/keys/{id}/grantandPOST /admin/groups/{id}/grantapply atomic budget top-ups (ADR 009);PATCHadjusts limits and enable/disable. - Reconciliation (pull, backend from gateway):
GET /admin/usage/exportstreams invoice-grade raw usage rows (ADR 010).
The third leg, signals from the gateway to the backend, does not exist. A
backend that sells prepaid credit (e.g. metering through Stripe) can only
poll to learn that a customer’s budget is nearly consumed. Hard budgets make
that a poor fit: the moment the pool reaches zero, the customer’s requests
are refused with 402 LM-4001. An auto-recharge must fire before that
moment, and polling trades latency against load in a way webhooks do not.
Constraints inherited from the pillars and prior ADRs:
- Nothing new on the request path; the database is never consulted per request and neither is any network endpoint that is not the routed provider (pillar 1, rule 4).
- Zero telemetry by default: today the gateway makes outbound calls only to
configured providers. Any new outbound traffic must be strictly opt-in and
carry no prompt or response content (pillar 2,
usage_log’s no-content construction). - Budget enforcement and settlement are in-memory atomics, flushed to
SQLite on an interval; a crash loses at most
flush_interval_msof accounting (M5, ADR 007). - The admin API remains the one control surface; ADR 010’s console pulls and pushes through it and reports drift rather than healing it.
Decision
An opt-in webhook sender for budget lifecycle events, delivered from the accounting layer, never from the request path.
1. Events
Three event families, all derived from state the gateway already tracks:
budget.threshold: a key’s or group’sbudget_spent / budget_maxcrossed a configured percentage. Thresholds are configurable (thresholds = [50, 80, 95]); each crossing is edge-triggered: one event per threshold per budget epoch, not one per request beyond it. Agrantthat drops consumption back under a threshold re-arms it (a new epoch begins whenbudget_maxchanges).budget.exhausted: the first admission refused withLM-4001for a key or group since it last had budget. Also edge-triggered.key.disabled,key.rotated,key.deleted: administrative lifecycle changes, so a backend registry can stay in sync instead of discovering drift on the next reconciliation pull (ADR 010 surfaces drift; these events shrink the window in which it exists).
The payload carries accounting facts only: event type, an event id, the key
or group id and name, budget_max, budget_spent, the crossed threshold,
and a timestamp. Never a plaintext key, never metadata, never content.
2. Detection is free, delivery is decoupled
Budget settlement already performs an atomic fetch_add per request.
Threshold detection adds a compare of the before/after values against the
armed thresholds on that same settle, in memory. When a crossing is
detected, the event is pushed into a bounded mpsc channel consumed by an
async sender task, exactly the usage-log writer pattern (rule 4):
- A full channel drops the event and increments
lumen_webhook_dropped_total. Webhooks are a convenience signal, not an accounting system; the export route remains the source of truth and the backend must reconcile against it. - The sender delivers with at-least-once semantics: bounded exponential
backoff (with jitter, capped attempts), then the event is dropped and
lumen_webhook_dead_totalincrements. Delivery state is memory-only; a restart forgets undelivered events. This is deliberate: persisting a delivery queue would put a new writer on the DB and buy little, since the reconciliation pull already covers gaps. - Shutdown cancels the sender without blocking: in-flight attempts are aborted through the same CancellationToken discipline as provider calls.
3. Authenticity and idempotency
Each delivery is a POST with:
x-lumen-signature: HMAC-SHA256 over the raw body, keyed by a secret read from an env var named in config (signing_key_env, the provider-key pattern; the secret is never logged, never in errors, never in Debug).x-lumen-event-id: a unique id per event (not per attempt), so retries and post-crash re-fires are deduplicable by the receiver.x-lumen-timestamp: to let receivers reject stale replays.
Because a crash can lose up to flush_interval_ms of settled accounting,
the same threshold can legitimately re-fire after a restart. Receivers must
treat events as idempotent signals (the Stripe webhook contract), and grant
routes are already atomic increments, so a duplicated auto-recharge trigger
is absorbed by receiver-side dedup on the event id.
4. Configuration
[webhooks]
url = "https://backend.example.com/lumen/events"
signing_key_env = "LUMEN_WEBHOOK_SECRET"
events = ["budget.threshold", "budget.exhausted", "key.disabled"]
thresholds = [50, 80, 95] # percent, for budget.threshold
channel_capacity = 1024 # bounded; full = drop + counter
timeout_ms = 5000
max_attempts = 5
No [webhooks] block means no sender task, no outbound calls, no behavior
change. The block participates in hot reload like every other section
(ADR 008): an invalid block rejects the reload, a changed URL or event set
swaps atomically.
5. What this is not
- Not a generic eventing system: no per-request events, no usage streaming (the export route is for that), no fan-out to multiple receivers in v1 (one URL; a backend can fan out itself).
- Not guaranteed delivery: at-least-once while the process lives, dropped with a counter when the receiver is down past the retry budget. The pull API is the system of record.
- Not a replacement for ADR 010’s drift report: lifecycle events shrink the drift window; the console still diffs on its schedule.
Consequences
- A billing backend can close the loop: threshold event, Stripe charge,
granttop-up, all before the customer sees a 402. Today’s alternative is pollingGET /admin/usageon a tight interval. - The gateway gains its first non-provider outbound call. The sovereignty stance is preserved by the opt-in default, but docs must state plainly that enabling webhooks makes the gateway call the configured URL.
- New metrics:
lumen_webhook_sent_total,lumen_webhook_dropped_total,lumen_webhook_dead_total, delivery latency histogram. - Testing needs a wiremock receiver: exactly-one signed event per crossing, 5xx retry with backoff, overflow drop with counter, no secret in logs, clean cancellation on shutdown (issue #146 acceptance criteria).
Amendment 2026-08-26: the webhook configuration is an admin resource
§4 above made [webhooks] a config-file section, reloadable but with three
knobs that could only change across a restart (channel_capacity,
signing_key_env, and the presence of the block itself). That is the wrong
shape for the very caller this feature exists for: a billing control plane
provisions a gateway through the admin API, and cannot restart it or edit its
environment. The section stays, and it gains a control surface.
1. Routes
Master-key gated, alongside the other /admin routes:
GET /admin/webhooks- the live settings, secret-free. Reports which source they came from and whether deliveries are signed.PUT /admin/webhooks- replace every setting; applied immediately and persisted, so a restart comes up identically.DELETE /admin/webhooks- stop emitting, persisted.PUT /admin/webhooks/signing-key- store the HMAC secret, sealed at rest.DELETE /admin/webhooks/signing-key- forget the stored secret.
There is still exactly one receiver (§5 stands): these routes edit that receiver, they do not create a collection. Fan-out remains future work.
2. Precedence: a stored row wins over the file
Settings written through PUT land in a single-row webhook_config table in
the auth database. Resolution at boot and on every reload:
- A stored row with
enabled = 1wins outright. - A stored row with
enabled = 0means off, whatever the file says - soDELETEis not silently undone by the next reload. - No row at all falls back to the
[webhooks]file block (or to off).
The file therefore stays the declarative default for a GitOps deployment that
never calls the API, and the API wins for a control-plane deployment. This is
the provider-key rule (ADR 008) with the sources swapped: there, config-named
environment variables are primary and the database fills in; here the database
is an explicit operator override of a declarative default. The asymmetry is
deliberate - a PUT that the next reload reverted would be a bug, whereas a
provider key that the environment overrode is the documented contract.
GET names the source ("database" / "config" / "none") so drift between
the file and the live setting is visible rather than inferred, in the spirit of
ADR 010’s drift report.
3. Every field is editable at runtime
url,events,thresholds,timeout_ms,max_attempts,retry_base_ms: swapped in the live policy cell, as before.channel_capacity: the bounded queue is rebuilt. The new queue takes new events; the previous sender task keeps its receiver, drains whatever was already queued under the settings it had, and exits when its last sender is dropped. Nothing already accepted is discarded to change a capacity.- The signing secret: held in a swappable cell the sender reads once per event, so a rotation applies to the next delivery without restarting the task. Deliberately not per attempt: a signature covers a specific body, so re-reading the cell mid-retry would emit attempts the receiver cannot verify. Retries of an event already in flight therefore keep the secret they were signed with, and a rotation reaches the next event instead.
Enabling webhooks on a process that booted without them therefore works too:
the queue, the sender task and the Prometheus collectors are created on the
first enable, not at boot. A gateway that never enables webhooks exports no
lumen_webhook_* series at all, which keeps the opt-in default honest on
/metrics as well as on the wire.
4. The secret, and why it may cross the API
§3 above read the secret only from an environment variable named in config.
That is still the primary source, and still the only one for an operator who
manages secrets through their deployment system. But a control plane that
provisions a gateway it does not own the environment of needs a way in, so
PUT /admin/webhooks/signing-key accepts the secret in the body and seals it
with AES-256-GCM under the master key - byte for byte the
PUT /admin/provider-keys/{name} mechanism (ADR 008), including its threat
model: the database file and LUMEN_MASTER_KEY together decrypt it, either
alone does not.
Resolution order, evaluated at boot, on reload, and on every apply:
- The variable named by
signing_key_env, when set and non-empty. - The stored secret.
- Neither: deliveries are unsigned. Refused as an error when
signing_key_envwas named (that is a broken deployment, not a choice) and allowed with a loud warning when it was deliberately omitted.
No route ever returns the secret, in any form. GET /admin/webhooks reports a
boolean signed and the variable name, exactly as the provider surface
reports key presence without key material.
5. What this does not change
Detection, edge-triggering, payload construction, at-least-once delivery, idempotency and the “never on the request path” guarantee are all untouched. The admin surface configures the pipeline; it is not part of it.
Future work
- Multiple receivers with per-receiver event filters.
- Quota events (
quota.rpm_exhausted,quota.tpm_exhausted) once a sustained-rejection signal (as opposed to a single 429) is defined. - A persistent outbox, only if reconciliation-by-pull proves insufficient in practice.
ADR 012 - Config source abstraction: file and DB modes
- Status: accepted
- Date: 2026-08-29
Context
Dynamic configuration (providers, routing, resilience, pricing, tokenizer,
telemetry, image fetch, hot-reloadable auth knobs, webhooks) lives in one
TOML file. PUT /admin/config already writes the file back atomically and
hot-reloads (ADR 010), so the file is the single source of truth for that
plane, while operational entities (virtual keys, budgets, usage, encrypted
provider keys, webhook settings) live in SQLite.
Three needs are unmet: granular config endpoints (edit one provider without shipping the whole TOML), a file-less deployment mode for console-managed fleets (ADR 010), and a guarantee that a human-edited file and API writes can never silently diverge.
Prior art considered: Kong (mutually exclusive DB vs DB-less modes, node
settings always in a file), HAProxy Data Plane API (API mutations rewrite
the config file; the file stays authoritative), PostgreSQL ALTER SYSTEM
(machine-owned overlay with fixed precedence), LiteLLM store_model_in_db
(file and DB merged at load - the drift-prone anti-pattern we refuse).
Decision
1. Boot layer vs dynamic layer, chosen at boot
The config schema splits logically (no file-format change): a restart-only
boot layer ([server], log format, [telemetry], auth enabled/DB path,
the usage-log channel knobs, and a new config_source = "file" | "db"
key, default "file"; the master key is read from the fixed
LUMEN_MASTER_KEY variable, never from config) and the hot-reloadable
dynamic layer (everything else). The rule for the split is what a reload
can actually swap: [telemetry] becomes the Prometheus label set when the
metrics are registered and the usage-log channel is sized at startup, so
both are boot-layer; the tokenizer and image-fetch policy are rebuilt and
swapped by the reload, so they are dynamic. In file mode one TOML holds
both layers, exactly as today. In DB mode the boot TOML may contain only
boot keys; a dynamic key present there is a boot error, never a silently
ignored second source. config_source = "db" requires auth with a
database. Every Config field is explicitly classified into one layer;
the classification is exhaustive so a new field must choose.
2. One canonical document behind a ConfigSource trait
The dynamic config is TOML text with a content-hash version, behind a trait
with load(), compare-and-swap persist(toml, expected_hash), and change
notification. FileSource wraps the existing staged-write + .bak +
rename + watcher machinery. DbSource is an append-only config_versions
table (toml, hash, applied_at) in the existing SQLite; current = latest
row; persist is a single transaction that inserts only if the stored hash
matches; the newest 50 versions are retained. The version token exposed to
API clients is the content hash in both modes, so If-Match semantics
(LM-1004 on staleness) are identical and clients cannot tell the modes
apart. Both backends are called only from spawn_blocking or the reload
task: the DB stays off the request path (pillar 3).
3. Granular endpoints share one mode-agnostic pipeline
New endpoints (PUT|DELETE /admin/config/providers/{name}, generic
GET|PUT /admin/config/{section} for scalar sections) and the existing
whole-document PUT /admin/config all run: apply-lock -> load() ->
If-Match check -> toml_edit patch (comments survive in file mode) ->
full boot-grade validation -> persist() -> hot reload. A granular edit
can never produce a document a restart would refuse. Boot-layer keys are
refused on every write path (LM-1001, “restart-only”). Secrets never
enter the document (env-var names only, as today), so config_versions is
no more sensitive than the file. /admin/provider-keys and
/admin/webhooks keep their exact semantics, including ADR 011’s
DB-row-wins precedence.
4. Drift is impossible by construction, and a sick source never strips a gateway
Exactly one store is authoritative per deployment; there is no merge and no
seed-then-diverge copy. Concurrent writers are resolved by the hash CAS: in
file mode a human file edit changes the hash and a stale API write gets
412, in DB mode the transaction refuses the same way. On reload, a load or
validation error keeps the previous good config (the ADR 008 rule extended
to the whole document). First boot in DB mode with an empty table starts
with an empty-but-valid config (no providers) and logs that a PUT /admin/config is expected, making fleet bootstrap a pure API flow.
Consequences
- Operators choose per deployment: GitOps-able file (API still fully usable, HAProxy-style write-back) or file-less API-driven (Kong-style DB mode), with one shared code path and identical API contracts.
- Mode migration is an operational procedure, not a feature:
GET /admin/configemits valid TOML in both modes; export/import CLI is backlog. - Config history exists in DB mode (versions table) but has no API in v1; rollback endpoints are backlog.
- The
[auth]section’s field-by-field layer split must be maintained as fields are added; the exhaustive classification makes forgetting a compile-time error rather than a runtime surprise. - No new error codes:
LM-1001,LM-1003,LM-1004widen their documented meanings indocs/errors.md.
ADR 013 - SystemOne (typed decision) capability and the TypeSafe provider
- Status: superseded in part by ADR 017 (public format, capability name)
- Date: 2026-09-26 (amended 2026-09-26: Jev as a reranker)
Context
TypeSafe’s Jev is the first “System One” model: instead of generating text it
answers typed questions about a state with calibrated probabilities. Its
whole API is one endpoint:
POST https://api.typesafe.ai/v1/systemone
Authorization: Bearer <key>
{ "model": "jev-latest",
"state": "<string | object | array>",
"questions": { "<id>": { "type": "noul" | "choice" | "score",
"instructions": ..., "criteria": ... } } }
The response carries one typed answer per question id (a noul probability,
a choice with its distribution and confidence, or a score with its
legend, distribution and confidence), the versioned model that answered,
and usage.{input_tokens, output_tokens}. Pricing is per input token only.
None of LUMEN’s three capabilities fits. Chat is free text; embeddings are vectors; rerank is a single relevance score per document with no notion of named questions, options or levels. Squeezing Jev into one of them would lose the thing that makes it useful (the typed, multi-question answer) while the operator still wants the gateway’s keys, budgets, quotas, token accounting, fallbacks and metrics in front of it.
Decision
-
A fourth first-class capability,
systemone.Capability::SystemOneserializes as"systemone"everywhere a capability string appears (capabilities = [...]in config,GET /v1/models, the Prometheuscapabilitylabel,usage_log.capability, the admin usage filter). The name follows the model class rather than a vendor, so a second System One vendor slots in as another provider kind. -
POST /v1/systemone, wire-compatible with TypeSafe. The request body is TypeSafe’s, unchanged; the response is TypeSafe’s plus, only when the gateway had to estimate, ausage.estimated: trueflag (ADR 003). The TypeSafe SDKs readTYPESAFE_BASE_URL, so pointing it at LUMEN (andTYPESAFE_API_KEYat a LUMEN virtual key) makes an existing Jev client go through the gateway with no code change.GET /v1/modelskeeps LUMEN’s OpenAI shape, so the SDKs’models.list()is the one call that does not work through the gateway; documented, not papered over. -
Validation at the edge, answers passed through. LUMEN forwards a non-2xx upstream as
LM-3003without its body (secrets and upstream detail never reach the client, CLAUDE.md rule 8), which would turn every TypeSafe422into an opaque 502. So the gateway validates the documented request contract itself and answersLM-1001with a precise message: non-emptymodel, astate, a non-emptyquestionsmap (LM-2011when empty, the SystemOne twin ofLM-2010), each question’stypeone ofnoul/choice/scorewith non-nullinstructions,choicecriteria a non-empty map of at most 255 options,scorecriteria an array of at most 10 levels, and no duplicate question ids. Duplicate top-level keys are rejected while parsing, so what is validated is exactly what is forwarded. An unknown questiontypeis rejected: a new TypeSafe question type is a LUMEN upgrade, not a silent passthrough.instructions,criteriaentries andstatestay opaque JSON (strings, objects or arrays are all legal upstream). Unknown top-level request fields and every response field are carried verbatim and in order, so LUMEN never has to understand an answer to serve it. The request body is shared behind anArc, so a retry or fallback attempt costs a refcount, not a copy of the state. -
SystemOneProvidertrait,typesafeprovider kind. One method,evaluate(req, cancel), with the usualCancellationTokencontract. Thetypesafekind defaults its base URL tohttps://api.typesafe.ai(overridable), authenticates with a bearer key, and maps429/529through the shared status classifier (529is a retryable 5xx, so the executor retries and falls back as for any overloaded upstream; aRetry-Afteron a 429 is honoured). Fallback chains, per-model timeouts, retries and circuit breakers apply unchanged, e.g. a pinnedjev-1.13.0falling back tojev-latest. -
Token accounting (ADR 003). Upstream
usage.input_tokensandoutput_tokenswin when reported.usageis parsed leniently: a missing, null or malformed block (or a zero input count) never fails a billed answer, it just falls through to the estimate. In that case (the SDK schema marksusageoptional) the gateway estimates input as the byte heuristic over the raw JSON text ofstateplus every question, which is what TypeSafe ingests, and flags itestimated; output is estimated as zero, since answers are tiny and Jev does not bill them. Admission reserves the same input estimate before the call. Cost uses the existingcost_per_1m_input/cost_per_1m_outputmodel prices (Jev:0.042/0), so hard budgets bite with no new pricing field. -
Privacy.
state, questions and answers are request content: never logged, never inusage_log, never in errors, exactly like prompts.
Consequences
- One more route, one more trait, one more registry map; the request path is
the same shape as
/v1/rerank(resolve chain, admit, execute, settle), so no new latency or locking. - The edge validation duplicates part of TypeSafe’s contract and can drift
from it. Limits are pinned to the documented values (2026-09) and covered by
tests; an upstream loosening shows up as a LUMEN rejection, an upstream
tightening as an
LM-3003. - A per-key or per-tenant choice of rerank converter is NOT part of this
decision; see
docs/design/systemone-rerank-mapping.md.
Amendment (2026-09-26): Jev as a reranker
Superseded in part by ADR 014: the converter is no longer a
[providers.models.rerank]block on atypesafemodel. It is theremapof a target of a rerank virtual model, which also adds thescore,compositeandchoicestrategies. The mapping, packing and accounting below still hold.
A typesafe model that declares rerank is served by a converter,
TypesafeRerankProvider, which implements RerankProvider over the model’s
SystemOneProvider, so the whole /v1/rerank path (validation, ordering,
top_n, document echo, admission, fallbacks) is unchanged.
- Mapping.
state = {"query": <query>}; onenoulquestion per document, id = the document index, with structured instructions{"document": <text>, "question": <converter instructions>}and the converter’scriteria.true/criteria.false.relevance_scoreis the noul. One call carries many documents, so the query is billed once per call, and Jev evaluates questions independently, so documents never see each other. - Converter config. An optional
[providers.models.rerank]block (instructions,criteria.true,criteria.false), each defaulting to a generic relevance question. It is operator-authored, per model id, and rejected at boot on any other kind or capability. Clients cannot supply it. - Packing. At most 100 documents and about 48k estimated tokens per upstream call (under Jev’s 64k), up to 4 calls in flight; a document over about 4,096 estimated tokens is truncated (Cohere’s default), so one document always fits the 32k state-plus-question budget.
- Accounting. The summed upstream
input_tokensis rerankusage.total_tokens(if any call omits usage, the whole count falls back to the ADR 003 estimate). Rerank cost becomes search-unit price pluscost_per_1m_inputon those tokens; models priced only per search are unaffected.
ADR 014 - Virtual models
- Status: accepted
- Date: 2026-09-30
- Supersedes: the open proposals in
docs/design/systemone-rerank-mapping.md - Builds on: ADR 003, ADR 005, ADR 009, ADR 012, ADR 013
Context
Today a [[providers.models]] entry does four jobs at once: it is the
upstream binding (provider, upstream_id), the public id clients send, the
pricing record, and the home of routing logic (fallbacks, and the Jev
[providers.models.rerank] converter from the ADR 013 amendment). Mixing the
jobs makes each new behaviour (weighted split, conditional routing, request
templates, Jev answering /v1/rerank with per-tenant relevance rules) a new
field on a model that is supposed to be a plain binding, and it makes the
logic impossible to compose: a fallback chain cannot point at another chain,
and a Jev reranker cannot fall back to a classic reranker.
The goals are:
- One place for routing logic. Foundation models become pure upstream bindings.
- Zero measurable cost for requests that use no transform: the routing decision stays well under 1 us.
- Jev as a first-class reranker, including per-tenant relevance rules (via
switchon the budget group) and a cross-capability fallback to a classic reranker. - Every served request still reports tokens and cost against the foundation model that actually served it (ADR 003).
Decision
-
Two layers. Foundation models (
[[providers.models]]) are pure upstream bindings:id,upstream_id,capabilities,modalities, prices,release_date. Virtual models ([[virtual_models]]) carry the logic. They live in the dynamic config document (ADR 012): config file orconfig_versionsrow, hot-reloaded, editable through/admin/config. Both layers are callable by clients. They share one id namespace (allowed characters[A-Za-z0-9._:/-], non-empty); a collision is a validation error naming both definitions. Circuit breakers stay keyed by (provider, model), as in ADR 005. -
One capability and one strategy per virtual model. A virtual model serves exactly one of
chat,embed,rerank,systemone, which keeps remaps and validation unambiguous and makesGET /v1/modelstruthful. The strategy is one of:single: the one target.fallback: targets in order; move on when the failure matchesfallback_on.split: pick one target at random by integerweight; on a matching failure try the rest in descending weight order (ties keep declaration order). No stickiness.switch: the first target whosewhenmatches is used. A chosen branch never falls through to the next rule; its resilience comes from what it points at.
Targets are foundation ids or other virtual ids (composition by reference). Load-time validation: references exist; strategy shape (
singlehas 1 target,fallbackandsplitat least 2,switchhaswhenon every target but the last and none on the last,whenonly underswitch,weightonly undersplit,fallback_ononly onfallback/split,presetonly onchat); capability compatibility (or aremap); no cycles (the error prints the path, e.g.a -> b -> a); nesting depth at most 8; at most 64 flattened attempts per request (MAX_ATTEMPTS: afallbackorsplitsums its targets, aswitchtakes its largest branch, a foundation target is 1), so a DAG listing the same child several times per level cannot blow up the decide step; contents ofoverrides,presetandremap. Validation runs at boot, reload and on every admin write.listed = falsehides a virtual model fromGET /v1/modelswhile it stays callable. -
Typed fallback triggers (
fallback_on). Names:provider_error(retryable upstream 5xx including TypeSafe 529 Overloaded, an unreachable upstream, premature stream end before the first byte; a malformed response and non-retryable upstream statuses never trigger a fallback),rate_limited(upstream 429),timeout(connect, first-token and per-attempt timeouts),circuit_open, plus the opt-incontext_lengthandcontent_filter. A TypeSafe 529 is a retryable 5xx (ADR 013), so it matchesprovider_error, notrate_limited. The default when omitted is["provider_error", "rate_limited", "timeout", "circuit_open"], exactly the ADR 005 behaviour.context_lengthandcontent_filterneed each provider to classify its upstream error into a newProviderErrorvariant; a provider that cannot classify leaves the error as a plain client error and nothing fails over (first release: OpenAI and compatible hosts, Anthropic, Google/Vertex, Bedrock, Mistral, Cohere). Never a trigger: other client 4xx errors, a client cancellation (never touches a breaker, ADR 006), or any failure after the first streamed byte (ADR 004/005). Retries still happen inside each leaf first; the[resilience].total_timeout_msdeadline bounds the whole tree. -
switchconditions (when). Attributes:group(the key’s budget group, ADR 009),metadata.<key>(fromx-lumen-metadata, ADR 002), and request factshas_images,has_tools,stream,input_tokens(the ADR 003 estimate, computed lazily only when a rule references it) anddocuments(rerank). Not the virtual key id. Operators: bare value (equality),in,ne,gt,gte,lt,lte,regex(compiled at load). Keys in onewhenare ANDed;any = [ ... ]is a nestable OR. A missing attribute makes its comparison false; an attribute the capability never has is a validation error. -
Per-target overrides.
overrides = { set, default, drop }on a target, applied to the parsed request struct (not JSON paths) against a per-capability allow-list of fields (chat sampling and limit fields, embeddimensionsandencoding_format, reranktop_n, none for systemone). Unknown fields, fields outside the allow-list and wrong types are validation errors. Order: the requested virtual model’spresetfirst, then target overrides from the outermost virtual model inward, so the level closest to the foundation leaf wins. -
Chat presets.
preset = { system_prompt, system_prompt_mode, overrides }on a chat virtual model. Modes:prepend(default),replace,if_absent. The prompt is at most 32 KB, not blank, and counts in tokens and cost. A preset only applies when its own virtual model is the requested id; on a composed model it is ignored and validation warns. -
SystemOne rerank remap. A
remapon a target of a rerank virtual model, pointing directly at a foundation model that servessystemone, converts/v1/rerankinto SystemOne calls. Strategies:noul(default, one noul per document),score(one score per document, normalisedlevel / (levels - 1)),composite(1 to 8 weighted noul questions per document, weighted mean),choice(one question whose options are the documents; must fit one call, at most 255 documents, elseLM-1001before any upstream call). A static operator-authoredcontextis sent in the state next to the query. Batching and truncation follow the ADR 013 amendment. Accounting:usage.total_tokensis the sum of Jev’s reportedinput_tokens, cost uses the SystemOne model’scost_per_1m_input. Preset and remap text is operator-authored config: never client-supplied, never logged, never echoed in errors. -
Compiled at load, executed per attempt. On boot and reload the virtual models compile into a plan held in the resilience policy’s ArcSwap snapshot (replacing today’s
fallbacksmap); an in-flight request keeps the snapshot it started with. Leaves resolve through the provider registry by foundation id at request time; a leaf the registry no longer knows is skipped (the primary’s miss is still the routing error), never a 500. The decide phase is pure (no I/O, no allocation beyond a small inline vector): it walksSwitchandSplitnodes and flattens the result into an ordered attempt list, each attempt carrying its governing trigger set and route path. The existing ADR 005 executor then runs the list; its only change is that moving to the next attempt is decided by that attempt’s trigger set. TheCancellationTokenflows unchanged and a remap’s concurrent Jev calls share it. A criterion bench targets under 1 us for a 3-level plan containing a regex rule. -
Old fields removed, migration provided.
fallbackson a model, the[providers.models.rerank]block, and atypesafemodel declaringrerankare validation errors that name the model, point tolumen config migrateand print the converted snippet.lumen config migraterewrites old configs so clients keep working unchanged: a modelMon providerPwithfallbacksbecomes foundationP/Mplus a virtualM(fallbackoverP/Mand the old fallbacks); atypesafererank model becomes a virtual rerank model with anoulremap onto the provider’s SystemOne foundation model. File mode backs up to<file>.bakand writes atomically; DB mode writes a newconfig_versionsrow;--dry-runprints the result.
Consequences
- Observability. New
x-lumen-routeresponse header with the path taken (e.g.acme/chat>acme/chat-eu>mistral-large), a newusage_log.routecolumn (migration 0010), a new Prometheus counterlumen_virtual_model_requests_total{virtual_model, model_used}, counted for served requests (virtual ids are operator-defined, so cardinality is bounded), andvirtual_modelandroutetracing span fields (never prompts, presets or remap text).x-lumen-model-usedandusage_log.model_usedstay the foundation leaf that served, so tokens and cost are still booked to it (for a remap, the SystemOne model).GET /v1/modelslists virtual models with"virtual": trueand their single capability; foundation entries gain"virtual": false. - Request-path error codes. Unknown id
LM-2001, wrong capabilityLM-2002, exhausted attemptsLM-3004/LM-3020or the last upstream error, invalid configLM-1001on an admin write (boot error otherwise). Two codes are new (amended 2026-09-30, PR review): a classified context-length refusal that no target absorbed isLM-2012and a content-policy refusal isLM-2013, bothinvalid_requestwith the upstream 4xx status and the provider name, instead of theLM-3003502 every other upstream 4xx still gets (rule 8: the input is the client’s to fix, the provider did not fail). New cases are listed indocs/errors.md. - Admin API.
/admin/config/virtual_models(list), and per-idGET,PUT,DELETEwith the ADR 012If-Matchrule, plus a read-only/planroute that shows the fully resolved tree. Deleting a provider, foundation model or virtual model that a virtual model still references isLM-1001. - Breaking change, visible side effect. Renamed foundation ids change
usage_log.model_usedand Prometheus model labels (e.g.gpt-4obecomesopenai/gpt-4o). A model with several capabilities keeps only one virtual model under its old id (the first declared capability); a call for another capability returnsLM-2002until the operator adds a second virtual model (it still works throughP/M, without fallback). The CHANGELOG flags this with the migration steps. - Performance. The request path gains one hash lookup plus O(depth +
rules) of decide work and drops today’s per-request clone of a
Vec<String>of fallback ids. No lock, database access or shared mutable state is added. - Non-goals. Model-driven routing (
classify,semantic, spec 2); live-state routing (least-busy, latency or throughput sorting, per-target RPM/TPM caps, sticky sessions); per-request routing in the body (OpenRoutermodels/provider, Portkey inline configs); a global rewrite-rule layer; named reusable remap templates (remaps are inline on a target); presets for capabilities other than chat; per-key model allow-lists and per-tenant BYOK.
Amendment (2026-10-08, ADR 017)
A rerank remap targets any model serving decisions, not only Jev. The
noul strategy is renamed predicate; noul stays accepted as an alias and
lumen config migrate rewrites it.
ADR 015 - Lab integration: account refs and billing usage events
- Status: accepted
- Date: 2026-09-30
- Builds on: ADR 009, ADR 010, ADR 011
- Amends: ADR 011 §2 (billing events only)
- Amended: 2026-10-10 (platform contract v2)
- Amended: 2026-11-05 (bandwidth and BYOK units, ADR 018)
Context
The Meilisearch Lab is one Rails control plane shared by Scrapix, LUMEN and
glutony: accounts, keys, one credit ledger, SSO. Data planes integrate only
through contracts, because the Lab is transitional and Meilisearch Cloud’s
control plane replaces it later. Scrapix already reports usage as signed,
idempotent usage.recorded events to POST {LAB_URL}/internal/events from a
durable outbox.
LUMEN already has the control leg (ADR 010 admin push, ADR 009 groups and
atomic grants) and a best-effort reconciliation leg (/admin/usage/export,
fed by a usage_log channel that drops rows under pressure by design). It
has no billing leg that is exact.
Decision
- Key push-sync. The Lab drives
/adminover a private network with the master key (ADR 010). LUMEN never calls the Lab to authorize a request. - One group per Lab account per gateway is the account’s lease on a
shared credit wallet. The Lab tops it up with
POST /admin/groups/{id}/grant. Admission is unchanged. - Two opaque refs:
budget_groups.account_ref(the Lab account id) andvirtual_keys.external_ref(the Lab key id). A key is billable when its group has anaccount_ref. - Billing rides on budget settlement, from settled cost. Each key
carries a settled-cost counter (the real cost added in
settle; in-flight reservation estimates never touch it) and abilled_microwatermark. At every flush a billable key’s event is its settled cost since the watermark, so a delta is never negative. It is written tousage_outboxin the same transaction that persists the spend, and the watermark moves to the settled cost on commit. A non-billable key’s watermark is kept equal to its settled cost, so a key that becomes billable later never bills its past. Three rules keep this exact:- One flush, one lock. The periodic, shutdown and key-delete flushes share one function and one async lock, because the watermark moves only after the commit and overlapping flushes would bill a delta twice.
- Deleted keys are retired, not dropped. A retired key stays in every flush until a flush that includes it commits and no in-flight request still holds it, so its last spend (even from a request still streaming) is billed and a failed final flush is retried.
- Billability changes flush first and fail closed. With
[usage_events]on,PATCH /admin/keys/{id}withgroup_idandPATCH /admin/groups/{id}withaccount_reffirst flush pending spend, so it is billed to the old account. If that flush fails the PATCH answers 500 (LM-5001) and changes nothing; the caller retries.
- Durable delivery. A sender pushes due outbox rows in batches, signed
X-Lab-Signature: sha256=<hex HMAC-SHA256>, and retries with capped backoff until the Lab lists the id inaccepted. This amends ADR 011 §2 (“delivery state is memory-only”) for billing events only: webhooks are signals, these events are the bill. (Amended 2026-10-09: the signature now covers<timestamp>.<body>with instance headers, and one drop rule applies; see the amendment below.) - Units. LUMEN reports micro-USD cost plus request and token counts. The
Lab converts to credits. (Amended 2026-10-09: raw units plus
provider_cost_micro_usd; see the amendment below.) - Opt-in, boot layer. A
[usage_events]block enables it; without it the gateway is unchanged and makes no new outbound call.
Consequences
- Billing is exactly the real cost the budget settled, with the same crash window (ADR 009 §4): a crash loses at most one flush of spend from both.
- The Lab must accept
product: "lumen"andX-Lab-Signature, convert to credits, hold one secret per gateway, and run the lease top-up loop. - Per-request detail stays in
usage_logand the export, best-effort. - While billing is on, a database or outbox outage blocks key moves between
groups and
account_refchanges (they answer 500 until the flush works). Serving, top-ups and everything else are unaffected. - Residual: spend from a request admitted before such a change but settled after it follows the new membership.
Alternatives considered
- Per-request events: requires a lossless
usage_logpath and a firehose on indexing bursts. - Lab pulls
/admin/usage/export: inherits drops and retention, and breaks the push contract the Lab already implements. - Per-request introspection (Scrapix model): a network hop on the request path.
Amendment 2026-10-09: platform contract v2
Decisions 2, 3 and 4 stand. Decisions 1 and 7 are extended and decisions 5
and 6 are amended by the Lab’s platform contract v2 (meilisearch/lab,
docs/superpowers/specs/2026-10-08-lab-platform-contract-v2.md),
implemented in LUMEN 0.7.0:
- Per-instance credentials (decision 5 amended). The gateway is a Lab
instance:
LAB_URL,LAB_INSTANCE_ID,LAB_INSTANCE_SECRET(first class in[usage_events]asurl,instance_id,secret_env; the environment wins over the file, andsigning_key_envstays as a deprecated alias with one boot warning). Every batch carriesX-Lab-Instance-Id,X-Lab-Timestamp(unix seconds, fresh per attempt) andX-Lab-Signature: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<body>")>; the Lab rejects a timestamp more than 300 s off. The Lab holds the secret per instance; “one secret per gateway” in the consequences above is now the Lab’s own model. - Self-description. One boot-time
GET /internal/instances/me(bearer secret plusX-Lab-Instance-Id) confirms the credentials and logs the identity. Engines are hosted by Meilisearch only (decision A of the contract), so decision 3 above is unchanged: a key is billed when its group has anaccount_ref. A401or403, or a2xxanswer that is not this instance’s identity (malformed, anotherinstance_id, another product, a kind other thanhosted), refuses to boot. Any other answer or an unreachable Lab is a warning, never a request-path concern; it runs after the listener is bound and before serving starts, so it delays serving (HTTP requests,/healthincluded) by at mostusage_events.timeout_ms. - Units, not a price (decision 6 amended). The event reports
operation: "gateway",units {requests, tokens_in, tokens_out, tokens_estimated}andprovider_cost_micro_usd(the settled-cost delta). The lease snapshot,source,key_idandwindowleavedata, which the Lab-owned schema closes; the key id and window live indescription(key <id> <start>..<end>). The schema is vendored byte for byte atcontracts/vendor/lab/lab-events.schema.json, and the CI joblab-contract-driftfails on drift frommeilisearch/labwhen theLAB_REPO_TOKENsecret is set. - One drop rule (decision 5 amended). The first
2xxanswer that keeps an event out ofacceptedstarts a 24 h clock (first_skipped_ms); a later such answer at least 24 h after it drops the row (dropped_ms) with an error log naming the event id andlumen_usage_events_dropped_total. Non-2xx answers, timeouts and an unreachable Lab never start or reset the clock, so an outage still never drops a bill. - One gateway, many accounts (decision 1 extended). The Lab still
drives
/adminwith the master key, but a hosted gateway is shared between accounts, so an/admin/*call carryingX-Lumen-Account-Refis confined to that account’s groups and keys (lists filtered, foreign ids404 LM-1003like unknown ones, creation forced into the account) and refused on the platform-only routes (provider keys and checks, webhooks, config,/openapi.json,/health/providers,/metrics) with the new403 LM-4005. Webhooks are platform-only rather than filtered because the receiver is one gateway-wide setting.GET /admin/groups/{id}exposes the livespent_microandbudget_max_microthe Lab’s lease sync reads;PATCH(budget_max) andgrantset the lease. - Opt-in from the environment too (decision 7 extended). The block is
still the only switch and still boot layer, but
LAB_URLandLAB_INSTANCE_IDin the environment create it when the file has none (and overlay it when it does), so a hosted gateway is configured like Scrapix and glutony. Without the block and without those variables the gateway is unchanged and makes no new outbound call. - Out of scope here: Postgres, horizontal scaling with auth on, per-account provider keys (hosted LUMEN runs on Meilisearch’s keys, decision F of the contract).
The operator view of all of this is Lab integration.
Amendment 2026-11-05: bandwidth and BYOK units
Per-account provider keys are no longer out of scope, and usage events gain
the units bytes_in, bytes_out, byok_tokens_in and byok_tokens_out.
Bandwidth is charged to spent_micro at the group’s rate, never to the
settled-cost counter billing reads. See
ADR 018.
ADR 016 - The developer role and upstream error detail in the log
- Status: accepted
- Date: 2026-10-05
- Amends: ADR 014 (the upstream error body was “only searched, never logged”)
Context
OpenAI added a developer message role for instructions; for its models it
is equivalent to system (each is mapped to the other depending on the
model). Clients now send it: Meilisearch chat sends its system prompt as a
developer message for the openAi source with any model not starting with
gpt-3.5/gpt-4. ChatMessage.role is a free string, so the gateway parsed
the role but every translated provider only recognised system: Anthropic
received developer as a message role and answered 400, surfaced as
LM-3003 (reproduced on 2026-10-05 against lumen.meilisearch.com with
claude-sonnet-5-5). OpenAI-compatible hosts without the role (vLLM chat
templates, Mistral, the Azure GA api-version) fail the same way.
The failure took long to diagnose because the log carried only
request failed with the status: ADR 014 reads the error body to classify
context-length and content-policy refusals, but never logs it.
Decision
developerissystemwherever the upstream has nodeveloperrole.ChatMessage::is_system_role()(systemordeveloper) is the only test a translator may use for instructions:- Anthropic and Bedrock hoist both into the top-level
system, Gemini and Vertex intosystemInstruction, in message order. - Cohere keeps instructions inline and sends
developerassystem. - OpenAI-compatible pass-through (Mistral, Azure, Ollama chat, Groq,
Together, vLLM and every other compatible host) rewrites
developertosystemin place (developer_role_as_system). - Only OpenAI itself gets
developerverbatim: theopenaikind with its built-in URL or abase_urlwhose host isapi.openai.com(OpenAiProvider::with_native_developer_role).kind = "openai"is also the documented way to reach unlisted compatible servers (LiteLLM, llama.cpp), so any otherbase_urlgets the rewrite. - Virtual-model presets treat a client
developermessage as a client system prompt (replaceremoves it,if_absentsees it).
- Anthropic and Bedrock hoist both into the top-level
- The upstream’s error message is logged, bounded and redacted. The body
of every upstream client error but 429 is read (still at most 16 KiB and
500 ms; 5xx bodies stay unread so a stalled body never delays failover).
classify_errorlogs onewarnline,upstream returned an error, withprovider,statusandupstream_error. That field is built only from the vendor’s message string (error.message,message,error_description, stringerror,detail, or the same in the first item of a JSON array). With no such string nothing is logged: never the raw or structured body, which can quote the request (FastAPI/pydanticdetaillists carry aninputfield). The string is cut at the first request echo ('input',"input",input_value,input=), whitespace collapsed, probable credentials replaced by<redacted>(known key prefixes such assk-,AIza,AKIA,gsk_, a 20+ character segment mixing letters and digits, or the value an upstream prints after a credential word such askey,tokenorpassword), then cut to 512 characters. It sits under the request span, so it shares therequest_idof therequest failedline. The body is still never returned to a client.
Consequences
- Meilisearch chat with
source: openAiworks against any LUMEN model; thevLlmsource workaround is no longer needed. - A provider added later must use
is_system_role(), notrole == "system", or the regression returns; the shared tests cover each existing translator. - Vendor error messages describe the request (field paths, limits, model
ids). Known request-echo shapes are cut and bodies are never logged, but
a vendor could still phrase a message around a fragment of the input; the
512-character cut bounds that. An operator who cannot accept it filters
the event (target
lumen_providers::mapping) at the subscriber, asdocs/operations/logging.mdshows. - A client that keeps sending requests the upstream rejects now produces
two lines per request (
warndetail plusrequest failed). - Rewriting to
systemloses nothing: OpenAI defines the two roles as equivalent, and no other upstream distinguishes them.
ADR 017 - Decisions, one capability for every decision-model vendor
- Status: accepted
- Date: 2026-10-08
- Supersedes: ADR 013 public format (
/v1/systemoneas the only shape) - Amends: ADR 014 (a rerank
remaptargets any decision model; strategynoulrenamedpredicate)
Context
Decision models answer typed questions with calibrated probabilities. Since
ADR 013 LUMEN served one vendor (TypeSafe Jev) in its own wire format. In
October 2026 OpenAI (gpt-6-luna, POST /v1/decisions, own format),
Perplexity (pplx-decider-*, TypeSafe format at /v1/decisions), Cloudflare
(Clef, Workers AI), Ollama (local Nimble, Clef, Kev) and several
TypeSafe-format vendors (Liquid, Inception, Upstage) shipped decision models.
The TypeSafe format is the de facto standard; OpenAI is the only vendor with
its own format, backed by SDK reach.
Decision
| # | Decision |
|---|---|
| D1 | One capability, renamed systemone to decisions. |
| D2 | POST /v1/decisions is the public endpoint. It accepts both formats, detected from the body, and answers in the format it received. |
| D3 | POST /v1/systemone is deprecated: a TypeSafe-format-only alias of /v1/decisions, removed one minor release later. Superseded by the 2026-10-11 amendment below: removed outright in 0.7.0 (404 LM-1003). |
| D4 | The core type follows OpenAI’s schema (ordered questions, optional names, images, refusals), widened so text fields may hold structured JSON. |
| D5 | Five existing provider kinds serve decisions in this change: typesafe (path now configurable, so it is also the generic kind for Liquid, Inception, Upstage and a self-hosted Kev), and openai, ollama, cloudflare and perplexity (new decisions capability next to their existing ones; perplexity and cloudflare are today OpenAI-compatible chat kinds). decisions on the existing openrouter kind is a follow-up. |
| D6 | The rerank remap targets any decision model; noul strategy renamed predicate (alias kept). |
| D7 | Jev rerank prompts stay byte-identical to today (structured {document, question} instructions). Comparing structured vs plain-text prompts is a follow-up evaluation. |
| D8 | Image support reuses the existing per-model modalities ("image") and LM-2003. |
| D9 | Same-family calls stay faithful in both directions: a TypeSafe-format request to a TypeSafe-family upstream sends the client’s question bodies and state verbatim, and the client receives the upstream answers (including legend and any unknown fields) verbatim. |
Consequences
capability = "systemone"becomes"decisions"(alias with a boot warning,lumen config migraterewrites it); theGET /v1/modelsvalue and thecapabilitymetric label change (breaking, CHANGELOG)./v1/systemonewas planned as deprecated in 0.6.0 and removed in 0.7.0, withDeprecationandLinkheaders and alumen_deprecated_requests_total{route}counter.- Amendment (2026-10-11): the deprecation landed after 0.6.1 and never
shipped in a tagged release, so
/v1/systemoneis removed outright in 0.7.0 (it answers404 LM-1003), together with its headers, its counter and the core “forced TypeSafe format” parse path. TypeSafe SDK clients, which call/v1/systemone, must call/v1/decisionsinstead. - An incompatible fallback target (image to a text-only model, a choice of one option to OpenAI, …) is skipped before any upstream call; it is not an attempt and never a circuit failure.
- TypeSafe-format clients keep receiving TypeSafe’s bytes from TypeSafe-family
upstreams; a refusal from an
openaitarget is rendered as{"type": "refusal"}, which TypeSafe SDKs may not deserialize.
Alternatives rejected
- One endpoint per vendor format (
/v1/decisionsOpenAI only plus/v1/systemone): Perplexity and Inception clients POST TypeSafe bodies to/v1/decisions, so the path cannot decide the format. - A TypeSafe-shaped core type: cannot carry ordered unnamed questions, boolean choice values, images or refusals.
- One codec per TypeSafe-format vendor: they differ only in URL, auth,
image placement, limits and envelope, which is data (
FamilyProfile).
ADR 018 - Team provider keys (BYOK) and bandwidth billing
- Status: Accepted
- Date: 2026-11-05
- Amends: 015
- Builds on: ADR 003, ADR 009, ADR 010
Context
The hosted gateway (ADR 015) runs on Meilisearch’s provider keys and bills the provider cost. Teams want to bring their own provider keys (“bring your own key”, BYOK): the provider bills them directly, so the Lab must not charge the token cost again. The hosted gateway then still carries their traffic, so the Lab bills the bandwidth instead.
Decision
- Pools per budget group. On the hosted gateway one Lab account is one
budget group, so a group owns the team keys: up to 10 keys per
(group, provider), each with a label (1 to 64 characters), plus a
per-provider
fallbackflag (default off). Keys are sealed at rest with the master key, never returned (onlylabel,last4, status) and never logged. Only kinds that take a plain API key on the vendor URL are eligible; Azure, Bedrock, Google/Vertex and keyless kinds are not. A pool row is trusted only when its provider is in the live config and eligible. The admin API is/admin/groups/{id}/byok(list),PUT .../{provider}(fallback flag),POST .../{provider}/keys,DELETE .../keys/{key_id}, account-scoped like the other group routes.DELETEevicts the key from memory synchronously; a reload then reconciles with the store. The request path reads pools from memory only. - Routing (
RequestKeys). Pools are chosen by the authenticated key’s group, never by client input. Keys are used round-robin. A 429 benches the key formax(retry_after, 30s); a 401/403 marks it failing and benches it 10 minutes; either way the next key is tried. When no key is usable, the platform key is used if the provider’s fallback is on, else the request fails withLM-3021(429,TeamKeysExhausted), decided by the gateway: never retried, not billed. With fallback off, the lock is decided up front from chain order: once a fallback-off team link is reached, later links with no team pool for their provider refuse withLM-3021without calling upstream, so a chain can never silently reach the platform’s keys. - Team-served requests settle non-billable. No token cost is added to
the settled or spent counters and the
usage_logcost is 0. Their tokens are reported asbyok_tokens_in/byok_tokens_out. - Bandwidth metering. A
/v1middleware inside auth counts request bytes read plus response bytes written, SSE included. The response side is credited exactly once, on end of stream or on drop, and also when the client disconnects before a response exists. A request is billed only when an upstream call was actually sent:UpstreamMarkis set inside the closures that call upstream. Gateway refusals (auth, invalid request, budget, RPM/TPM, team keys exhausted) are not billed. - Bandwidth charge. Bytes are charged to the key and group
spent_microat the group’sbandwidth_micro_usd_per_gb(integer, ceil, 1 GB = 10^9 bytes), never to the Lab billing countersettled_micro, so the ADR 015 settled-cost event is not double counted. A group with abudget_maxrefuses when spent + reserved >=budget_maxeven for a zero token estimate (LM-4001): zero credits is a hard stop, which BYOK-only teams would otherwise bypass. The Lab pushes the rate asfloor(price / provider_cost_markup), so that the lease, which is in provider-cost space, is debited the customer price after the Lab applies its markup. A rate of 0 disables the charge. - Usage events. New units
bytes_in,bytes_out,byok_tokens_in,byok_tokens_out.tokens_in/tokens_outandprovider_cost_micro_usdcover platform-key traffic only. A flush window with only bandwidth or BYOK traffic still emits an event (provider cost 0). The vendored schemacontracts/vendor/lab/lab-events.schema.jsonis updated to match.
Consequences
- Circuit breakers are scoped by who serves the link. A link the request
serves from a team pool (its provider has a usable pool of an eligible
kind when the request starts) uses its own breaker, keyed
"{provider}#group:{group_id}"with the same model id; every other link keeps the platform breaker keyed by (provider, model). Platform 429s or 5xx therefore never open the breaker for a team served on its own keys, a team’s failing keys never open it for platform traffic, and a team-key success never closes (or takes the probe of) a breaker the platform opened. Keys of one pool still share their group’s breaker; per-key breakers remain a possible follow-up. A team-scoped link that ends on the platform key (fallback on, every team key benched mid-request) is charged to the team scope. Scoped breakers stay out of thelumen_circuit_stategauge, which would otherwise grow one series per group. - A link refused by the chain lock, or any other result that says nothing about the provider (a cancellation, a client fault), hands back a half-open probe it took, so the next request probes without waiting a cooldown.
- When the chain lock refuses a link after an earlier link of the same
request failed upstream, the client gets that earlier error rather than
LM-3021; a team link whose own pool is exhausted still answersLM-3021. - Bandwidth is metered at the HTTP layer, so it counts body bytes only (no headers or TLS).
- Narrows ADR 015 decision 7 (out-of-scope list): “per-account provider keys” is now in scope for plain-key providers.
Rollback
Migrations 0015 and 0016 are additive. sqlx refuses to boot an older binary on a migrated database. If one is forced to run, it ignores the pools: BYOK teams would be served on the platform’s keys and billed tokens. So roll back by disabling BYOK (the per-pool fallback flag cannot help), rather than by downgrading the binary.
Backlog
Ideas surfaced during development that are intentionally out of scope for v1
(see CLAUDE.md → “What we do NOT do (v1)” and ROADMAP.md → “Backlog v2”).
Recorded here so they are not lost, and so we don’t gold-plate the current
milestone.
Noted while building ADR 009 (shared parent budgets)
- Grant routes are not idempotent (ADR 009 amendment). A timed-out
POST /admin/{keys,groups}/{id}/grantmay have landed; the docs tell billing automation to verify with a GET before retrying. A client-suppliedIdempotency-Keyheader (dedup table keyed on it, replay the recorded response) would make retries safe; add it when a real billing integration asks. - Groups carry budget only. Group-level RPM/TPM, a group
disabledflag (pause a whole customer), group expiry, and nested groups are all deliberate non-goals of the first slice; each is a natural follow-up on the sameGroupEntryshape. - No
lumen groupsoffline subcommand. Groups are created viaPOST /admin/groups(master-key gated, no virtual key needed), so there is no bootstrap chicken-and-egg;lumen keys create --group-idcovers the offline key path. Add the subcommand only if a real workflow needs fully offline group provisioning. - No
lumen_group_budget_remaininggauge. Pool spend is visible viaGET /admin/keys-style reads (GET /admin/groups) and the usage log; a Prometheus gauge per group would be cheap but adds a per-group metric series - decide when a dashboard actually wants it. - Atomic budget top-up.
PATCHon a key or group budget is a read-modify-write for the caller; aPOST .../grant {"amount"}increment route would remove the need to serialize concurrent top-ups in the control plane.
Noted while building ADR 013 (SystemOne capability)
- Byte-splice passthrough for TypeSafe-format
/v1/decisions. The request is parsed into per-question raw boxes and re-serialized for each upstream attempt. Since the only mutation ismodel, the handler could keep the client’sBytes, validate through one borrowed pass, and splice the upstream model id into the recorded byte range per attempt (.body(..)instead of.json(..)). The perf audit measured about 255 us down to about 125 us at maximum size (128 KB state, 300 questions); a typical request is already ~3 us. The same applies to the response: forward upstream bytes untouched when no usage estimate has to be injected. Jev-backedDone (ADR 014): a rerank virtual model whose target carries a/v1/rerank.remap(noul,score,composite,choice, staticcontext), with per-tenant rules throughswitchon the budget group and a Cohere fallback. Design notes indocs/design/systemone-rerank-mapping.md.GET /v1/modelsin TypeSafe’s shape. The TypeSafe SDKs’models.list()expects{"models":[{name, description, release_date}]}; LUMEN keeps the OpenAI list shape, so that one SDK call does not work through the gateway.strip = truebreaks release builds of proc-macro dylibs on macOS 27 (mis-aligned LINKEDIT string poolloadingsqlx-macros), socargo benchfails locally unlessCARGO_PROFILE_RELEASE_STRIP=false. Considerstrip = "debuginfo"or a separate bench profile.
Noted while building ADR 017 (decisions capability)
Follow-ups of the decisions spec (docs/superpowers/specs/2026-10-08-decisions-design.md, section 15):
- Spec 2: classify / auto-router on the core decision type.
- Evaluate structured vs plain-text rerank prompts on all three vendors, then simplify the byte-identical Jev prompt rule if they rank the same.
- Raise OpenAI and Perplexity rerank packing limits once measured or published.
- Perplexity’s embeddings API as a capability of the
perplexitykind (chat is already served through its OpenAI-compatible endpoint). decisionson the existingopenrouterkind (/api/alpha/decisions): one key for about 15 models, including Respan and Tev1; needsnoulcriteria synthesis andproviderrouting preferences.- Respan’s native
/scoresAPI and Together’s chat-based Tev1 (reachable through OpenRouter meanwhile). - An integration test against a real local Ollama (
nimble) in CI, and one manual call against a real Cloudflare account at release time. - A true per-image tile count for token estimates.
Noted while building ADR 014 (virtual models)
- Model-driven routing (spec 2).
classify(one SystemOnechoicecall to Jev whose options are the targets’ descriptions; the most probable option wins, belowmin_confidencethe default target is used) andsemantic(target descriptions embedded at load, one embedding call per request, a dot product per target, belowmin_similaritythe default). Both add one upstream call before the real one, so they are opt-in per virtual model; a router failure routes to the default, never fails the request; the router call’s tokens and cost are recorded separately (ADR 003). - Named, reusable remap templates. Remaps are inline on a target today, so several tenants sharing one relevance rule repeat it. A named template referenced by id would remove the duplication.
- Presets for non-chat capabilities.
presetis chat-only. Embed (defaultdimensions) and rerank (defaulttop_n) can already use overrides on a target; a stored template for them is not planned yet.
Noted while building ADR 012 (config source abstraction)
- Config history/rollback endpoints. DB mode already keeps the newest 50
config_versionsrows (append-only, pruned on each write), but there is no admin route to list or roll back to one - v1 ships the storage, not the API. AGET /admin/config/history(id, hash,applied_at) and aPOST /admin/config/rollback/{id}(re-persist an older row’s TOML as a new version, through the same validate/persist/reload pipeline every other write uses) would close this without a schema change. lumen config export/importCLI. File<->DB mode migration today is a manualGET/merge/restart procedure (see Config source modes). An offlinelumen config export --config <path>(dump the boot file’s dynamic keys, or a DB-mode document, as portable TOML) andlumen config import(the reverse: install exported TOML through the admin API, or write it into a file-mode boot file) would make the procedure a single command instead of copy-paste.- Per-model granular endpoints. The granular admin API (ADR 012) stops at
provider granularity:
PUT/DELETE /admin/config/providers/{name}replace or remove a provider’s entiremodelsarray along with it. APUT/DELETE /admin/config/providers/{name}/models/{id}pair for editing or removing one model without resending its siblings is a natural follow-up once a real console workflow wants it.
Deferred to v2 (from the vision)
- Web admin UI
- Semantic cache
- Audio (input/output) support. Image input already shipped in v1: chat vision (M8) and multimodal embeddings (M9).
- Guardrails / moderation
- Distributed rate limiting (Redis)
- OTLP tracing export
- WASM plugin system
- Postgres backend for the auth/usage store (a
postgressqlx feature flag; the queries incrates/authare simple enough to stay portable, so this is cheap to add if a deployment ever needs it - v1 is SQLite only) /v1/batches(OpenAI async batch jobs API) - explicit v1 non-goal: batch jobs imply persistent job state and scheduling, which sits poorly with the DB-off-the-request-path and single-binary pillars. Note:crates/providers/src/batch.rsis embedding request sub-batching, unrelated to this API surface./v1/files(OpenAI file upload/storage API) - explicit v1 non-goal: blob storage state, same rationale as/v1/batches.
Noted while building M1
Token-array inputs forResolved (issue #25)./v1/embeddings(inputas arrays of token ids) are not modelled - only string and string-batch.EmbedInputnow modelsTokens([1,2,3]) andTokenBatch([[1,2],[3,4]]); they pass through natively on OpenAI-compatible providers and count one token per id in the estimation fallback. Text-only providers (Cohere, TEI, Ollama, Jina, Voyage, Mistral) reject them with a 400 (LM-1001) before any upstream call.RerankResolved (issue #25). See the M3 note below.documentsaccepts only strings; Cohere also allows objects.error_type()collapses 401/402/429 intoinvalid_requestbecause the public taxonomy only has threetypes. Fine perCLAUDE.md, but note it’s coarse.- Acceptance criterion “boot < 100 ms” is verified manually (M1); fold a real timing assertion into the M7 criterion benchmarks rather than a flaky unit test.
Graceful shutdown is unit-tested via an injected shutdown future; the real SIGINT/SIGTERM path (Resolved (issue #27).shutdown_signal) has no integration test (hard to do portably). Acceptable; revisit if signal handling grows.crates/server/tests/signal_shutdown.rs(#[cfg(unix)]) spawns the reallumenbinary and sends it a genuineSIGTERM/SIGINTvialibc::kill, asserting the same drain-then-exit-0 behaviour the injected- oneshot tests already prove forserve()itself - now proven for the actualtokio::signalpath too.
Noted while building M2
Embedding output is always a float array in v1. Base64 embeddings are decoded on the way IN (a client requestingResolved (issue #25). Whenencoding_format: "base64"won’t error), but we do not re-encode on the way OUT.encoding_format: "base64"is requested, the gateway re-encodes each vector as OpenAI-style base64 at the response edge, so it works for every provider (including Ollama and TEI, which have no upstreamencoding_format).Ollama drops the OpenAI-onlyResolved (issue #25). A per-providerdimensionsfield with adebug!log; a client asking for a specific dimension silently gets full-width vectors.strict = truemakes Ollama reject a request that setsdimensionswith a 400 (LM-1001) instead of silently dropping it; the default stays lenient.encoding_formatis no longer lost either (handled at the edge, above).LM-1002(payload too large, 413) is emitted byRequestBodyLimitLayeras a raw 413 without our JSON error envelope. Map the tower-http rejection toGatewayError::PayloadTooLargefor a consistent body.- Cancellation tests use real (short) wall-clock delays rather than
tokio(start_paused); robust today but revisit if they flake under CI load. The HTTP-level disconnect test asserts the server stays responsive and the upstream got the request - the actual upstream abort is proven at the provider layer (conformancescenario_cancellation_aborts_upstream). Update (issue #27): this predicted flake happened -mistral_passes_embed_conformance_suiteflaked once under full workspace-test parallelism. Widened the mocked upstream delay (2s → 3s) and the elapsed-time assertion (1s → 2s) inscenario_cancellation_aborts_upstream(crates/providers/tests/embeddings.rs) for more scheduler-jitter headroom without weakening what the assertion proves (still asserts the call returns in well under half the mocked delay).tokio(start_paused)would sidestep wall-clock entirely but doesn’t compose with the realreqwest/wiremock I/O this suite exercises; deferred unless the wider margin still flakes.
Noted while building M3
- Cohere v2 embed requires an
input_type; the gateway can’t know query-vs- document intent by default, so it sendssearch_documentunless overridden. Resolved (issue #22): a caller may setinput_typeas an extra field on the/v1/embeddingsrequest body (search_document,search_query,classification, orclustering); an unknown value is rejected withLM-1001before any upstream call. Seedocs/providers.md§ cohere. A per-model default (config-side) is still open if per-request opt-in proves insufficient in practice. usage.search_unitsis only meaningful for Cohere; Jina and Voyage bill rerank in tokens. Resolved (issue #10):RerankUsagenow carries a separatetotal_tokens/tokens_estimatedpair, upstream-reported for Jina/Voyage and gateway-derived (fromquery + documents) for every other provider.RerankResolved (issue #25).documentsaccept string or{text}only. Cohere also allows arbitrary objects with arank_fieldsselector - out of scope.RerankDocument::Objectnow keeps all fields; the request carries an optionalrank_fieldsselector, and the gateway reduces each object document to a single ranking text at the edge (selected fields joined, or thetextfield when no selector), so providers still only ever see plain text. Withreturn_documents: true, an object document echoes that reduced ranking text indocument.text, not the original JSON object.- TEI serves one model per process and ignores the request
model/top_n; the gateway truncates totop_nafter sorting. The configuredupstream_idis informational for TEI. A future health/introspection hook could verify the configured model matches what the TEI process actually serves. - The four hosted rerank providers default
max_batch_sizeconservatively (Cohere 96, Jina/Voyage/OpenAI-style large, TEI 32). Revisit against real provider limits; embeddings batching already exercises these. - Per-model embedding
max_batch_sizeoverride (surfaced by issue #90). Vertex embeddings hardcodemax_batch_size() = 1becausegemini-embedding-001accepts a single instance per:predictcall, but the othertext-embedding-*models take up to 250. A single conservative value means a 1000-input request is ~250 sequential rounds at concurrency 4. A config override (or a larger known-safe default for the text-embedding models, keyed by model) would soften this bottleneck. Deferred: it needs per-model config plumbing that the currentEmbeddingProvider::max_batch_size(provider-wide, model-agnostic) does not carry.
Noted while building M4 (slice 1 - non-streaming chat)
- Streaming disconnect test is a no-hang assertion, not an abort assertion.
streaming_client_disconnect_does_not_hang_serverproves the server stays responsive but not that the upstream connection was actually closed (M4 acceptance criterion 2: “upstream closed in < 100 ms”). And because the interim single-shotchat_streamawaits the fullchat()before the guard is moved into the SSE body, the moved-guard path is not exercised. Strengthen in the streaming slice: assert via wiremock that the upstream request was aborted, and add a case where the client cuts after the body starts. - Anthropic
translate_requestcopies message roles verbatim and does not normalise user/assistant alternation or drop/mergetool/empty-content messages (spec 4.3 bullet). Fine for the interim text path; complete with tool translation in the streaming slice. - Anthropic responses set
created: 0(the API returns no timestamp). Some OpenAI clients expect a real epoch; setSystemTime::now()if one complains. to_sse_body’sChaindrops the mapping closure (and thus the cancel guard) as soon as the chunk stream is exhausted, a hair before the[DONE]frame. Benign today (nothing left to cancel once the upstream is done), but the real streaming slice must not tie a live resource to guard survival through[DONE].- Interim single-shot emits
[DONE]even after a mid-stream error frame. Harmless with one item; real streaming must terminate after an error.
Noted while building M4 (slice 2 - zero-copy streaming)
-
Acceptance criterion 5 (LM-3010) not yet implemented.Resolved (commit076b909, slice 3). When the upstream closes without a[DONE]terminator and without a transport error, the gateway now appends a terminaldata: {"error": {"code": "LM-3010"...}}frame then closes cleanly. The lightweight tail-watcher (EventStreamState::scan_frameincrates/server/src/chat.rs) inspects only frame boundaries - it matches a line-anchored\ndata: [DONE]marker and keeps at mostDONE_MARKER.len() - 1trailing bytes, so a terminator split across two frames is still detected and model content that merely contains the text can never spoof it. Covered by the unit guard tests in that module and the end-to-end wiremock testupstream_stream_without_done_yields_fg3010_error_frame(passthrough), plus the mid-stream error-frame tests (mid_stream_provider_error_becomes_terminal_error_frameandresilience.rs). -
Tools on Gemini: RESOLVED (issue #4).
translate_request(google) now maps OpenAItoolsto Geminitools[].functionDeclarationsandtool_choicetotoolConfig.functionCallingConfig; assistanttool_callsbecomefunctionCallparts, roletoolmessages becomefunctionResponseparts, and both the non-streaming and streaming translators surface GeminifunctionCallparts as OpenAItool_calls. Chose the full mapping over the LM-2002 rejection. Covered by unit tests and wiremock round-trips incrates/server/tests/chat.rs.
Noted while building M5
- Accurate per-model tokenizer - DONE (issue #8). ADR 003’s opt-in
“accurate tokenizer via
spawn_blocking” now ships behind[tokenizer] mode = "accurate": exacttiktoken-rsBPE (cl100k_base / o200k_base by model prefix) refines the local fallback AFTER the response is handed off (deferred accounting close, BPE on the blocking pool), landing in Prometheus and usage_log; the response envelope stays heuristic and the request is never delayed. Heuristic fallback for non-OpenAI models and any failure. The default stays the byte heuristic (zero cost). See the ADR 003 “opt-in accurate tokenizer” addendum. Remaining follow-up: streaming input counts stay heuristic (no buffered prompt to BPE under the ADR 004 passthrough rule). - Streaming output estimation = data-frame count. When a stream carries no
usage (rare:
include_usageis auto-requested and translators always emit usage), the output-token estimate is the number ofdata:frames (~1 token per delta for OpenAI-style streams). Crude but honest (estimated=true). - usage_log records successful requests only.
Refusals (401/402/429) and upstream failures are visible in logs/metrics but produce no usage row.RESOLVED (issue #26, ADR 007): admission refusals (402/429) now enqueue a status-only usage row (zero tokens,statuscarries the rejection) through the same non-blocking channel. 401 stays unlogged (refused in the auth middleware, before accounting opens - no key to attribute); upstream failures are already logged by the normal finish path. - Rejected requests still count toward RPM/TPM.
Quota bumps are not unwound when a later admission step (budget) refuses the request.RESOLVED (issue #26, ADR 007): a request refused inside admission (TPM after RPM, or the budget after both) now rolls back the bumps it already made, so it consumes no quota. (A request refused at its own step never bumped that window.) - DB-stored provider keys are boot-time only.
Done (post-v0.1.0): the admin route pings the hot-reload trigger and every reload re-reads provider keys from the encrypted store, so a rotation applies without a restart. Env-sourced keys keep precedence.PUT /admin/provider-keystakes effect at the next restart; wire it into the M7 hot-reload path. - Metadata values are stringified in
usage_log.metadata.JSON object of strings.RESOLVED (issue #26, ADR 007): the column now stores typed JSON ({"batch":42,"canary":true}), so numeric/boolean filtering via SQLitejson_extractworks. Prometheus labels still stringify (labels are strings). - TPM debits the pre-call estimate, never adjusted.
Unlike the budget (reserved then settled to real usage), the tokens-per-minute window keeps the estimate.RESOLVED (issue #26, ADR 007): a successful request now settles the TPM window to the real token count, mirroring the budget - largemax_tokensreservations no longer starve a key. A dropped (failed/cancelled) reservation deliberately keeps the TPM debit: a request that hit the gateway still counts against the rate limit. - No zeroization of key material.
MasterKeyand the raw env string are not zeroized on drop; thezeroizecrate would close the residual-memory window. Low risk (single long-lived process), noted from the M5 review.
M6 (resilience) - deferred
-
Per-provider connect timeout.Done (issue #24, 2026-07-15.) A provider may setconnect_timeout_ms; it then gets its own (unpooled)reqwest::Clientbuilt at registry construction, while every non-overriding provider keeps sharing the one pooled client. See the ADR 005 amendment. -
First-frame-peek streaming retry.DONE (issue #7, 2026-07-15). The commitment point moved from the open phase to the first content frame: the streaming path now peeks the first upstream frame, so a stream that opens 200 then errors or closes before any content frame fails over (and penalises the breaker) instead of committing to a terminal SSE error. Buffers at most one frame, bounded byfirst_token, cancellation-safe. See ADR 005, 2026-07-15 amendment;crates/router/src/peek.rs. -
Circuit-breaker map is unbounded by design. One entry per (provider, model) actually seen; bounded by the configured surface, never by client input. If a future dynamic-model feature lets clients mint arbitrary model ids, add an LRU cap.
-
Health probe is a bare GET to
base_urlfor keyed vendor kinds. TEI, vLLM and Ollama now get a real, unauthenticated liveness endpoint (DEBT-3, issue #23). Keyed vendor kinds (OpenAI, Anthropic, the OpenAI-compatible hosts, …) still get bare reachability only: their liveness routes (GET /v1/models, etc.) require an API key the probe task does not carry, and an unauthenticated call there would 401 a healthy server - a worse signal than bare reachability. Plumbing provider credentials into the probe task to unlock this is deferred to keep the probe free and side-effect-free. An on-demand, credentialed check now exists asPOST /admin/providers/{name}/check(crates/providers/src/key_check.rs); running it from the background probe is still deferred (it would change the probe’s no-credentials contract, so it needs an ADR first). -
Resolved (issue #27; the storm path’s kernel-level connect retry landed via PR #42). Several dev sandboxes hit a client-side panic (health_stays_fast_under_upstream_429_stormflaky under a 500-connection storm.reqwest::Errorfrom a TCP connect reset or a broken-pipeSendRequest) when firing 500 concurrent requests at once - a saturated OS accept backlog, not gateway behaviour.crates/server/tests/resilience.rs’spost_chathelper now retriesis_connect()/is_request()transport errors (never a received HTTP response) with a short backoff before giving up, and the storm size is overridable viaLUMEN_RESILIENCE_STORM_SIZE(defaults to the unchanged CI-scale 500) as a secondary escape hatch. The status-code assertions (429/503 only,/healthlatency bound) are unchanged - the fix is purely about not letting host-level connection churn panic the test.
M7 (release) - deferred
- Hot reload swaps routing, pricing, resilience and the safe auth knobs.
SIGHUP / file-watch / admin-trigger re-validate the config and atomically swap
the provider registry (ArcSwap), the price table, the resilience policy and the
runtime-safe
[auth]knobs (flush_interval_ms,retention_days). Still restart-only, by design: the server bind address (rebinding a live listener is high-risk),auth.enabled,auth.db_path, and the bounded usage-log channel knobs (usage_channel_capacity,usage_batch_max,usage_flush_ms) whose capacity is fixed when the channel is created. - Hot reload re-resolves env keys fresh; DB keys are re-read each reload. A
reload re-reads provider keys from the environment and, for env-keyless
providers, from the encrypted DB store, so rotating a DB-stored key
(
PUT /admin/provider-keys) after boot takes effect on the next reload (the admin route triggers one) with no restart. A DB read error keeps the previous snapshot so a reload never strips a stored key. Anthropic/Gemini translation fuzzing goes only as deep as the shared SSE parser today. Fuzzing theResolved (issue #27). Each oftranslate_request/translate_response/stream translators directly needs a small public (or#[cfg(fuzzing)]) shim over the currently-private functions.providers::anthropic/providers::googlenow has a#[cfg(fuzzing)] pub mod fuzzingshim (compiled only undercargo fuzz, which sets--cfg fuzzingacross the dependency graph - zero normal-build surface change) exposingtranslate_request/translate_response. Four new targets (anthropic_translate_request,anthropic_translate_response,google_translate_request,google_translate_response) infuzz/, wired into the weekly fuzz CI matrix; seefuzz/README.md“Why#[cfg(fuzzing)]shims” for the alternatives considered.Loaded throughput vs LiteLLM not measured in-repo. The in-process overhead (~3 µs) is benchmarked; the full p50/p99/RAM/req·s head-to-head is a reproducibleResolved (issue #27).bench/harness (docker-compose + k6) run by the operator, not captured as a committed baseline.bench/run.shdrives the full harness end to end (pinned, digest-locked images; one command) and writes a timestamped, committed result underbench/results/. A recorded baseline is linked fromdocs/perf-baseline.md- read that section’s caveat before trusting the absolute numbers: it was recorded on a shared dev host, not dedicated hardware, so the relative LUMEN-vs-LiteLLM comparison is solid but the absolute figures are illustrative. Re-runbench/run.shon real hardware for numbers to make capacity decisions on.- Streaming time-to-first-token in the k6 harness. The head-to-head now
reports non-streaming time to first byte (k6’s built-in
http_req_waiting), but a streaming TTFT column (first SSE chunk of astream: trueresponse, per target, under load) is not measurable with stock k6: it buffers response bodies, so the first body chunk cannot be timestamped. Doing it in the harness needs a body-aware load client - thexk6-sseextension (custom k6 build via xk6) or a small purpose-built Rust/Go client - plus a mock upstream that actually paces its SSE frames (mockserver writes the whole body at once). Until then the streaming first-bit number lives off-harness incargo bench -p server --bench stream_ttfb(single-request latency, direct vs via-gateway, no concurrent load).
Backlog debt paid down (post-v0.1.0)
- Full-config hot reload (DEBT-1) - done. Reload now swaps pricing and the resilience policy (retry/timeouts/fallbacks) as well as the routing table, preserving circuit-breaker state.
- Auth-knob hot reload + DB provider-key rotation - done. Reload also swaps
the safe
[auth]knobs (flush_interval_ms,retention_days) and re-reads DB-stored provider keys, andPUT /admin/provider-keystriggers a reload so a rotation applies without a restart. Server bind address stays boot-time (see the M7 note above for the exact restart-only surface). - Key-material zeroization (DEBT-2) - done.
MasterKeywipes on drop and the rawLUMEN_MASTER_KEYstring is zeroized after use. - Richer health probe (DEBT-3, issue #23) - done for the self-hosted,
keyless kinds: TEI (
/health), vLLM (/health) and Ollama (/api/version), each a real liveness endpoint (non-2xx = down). Keyed vendor kinds keep bare host-reachability (no reliable unauthenticated liveness endpoint); a per-kind, authenticated probe for vendor APIs remains out of scope (see the M6 entry above).
Noted while building M8 (vision - image input to chat)
- Per-image token heuristic for the estimation fallback - done (issue #9).
The estimation fallback (upstream reports no
usage) now adds a flat per-image constant (85tokens for"detail": "low",765for"high"/"auto"/unset) instead of counting an image part as0. A true per-dimension tile count (OpenAI’s85 + 170 * tiles) still needs decoded pixel dimensions, which the gateway does not extract from adata:URI today and remains out of scope (no image-byte inspection on the request path). See the ADR 003 addendum. - Anthropic/Gemini file/GCS image URIs. Only inline base64 (
data:URIs) and, where the provider fetches it itself (Anthropic), remotehttp(s)URLs are supported. Anthropic’ssource: {type: "file", file_id: ...}and Gemini’s GCSfileUrisources are not modelled; add if a caller needs pre-uploaded-file references instead of inline bytes.
Provider coverage - next candidates (post-rename)
- Tier-2 clouds need dedicated kinds (different auth/schema, not
OpenAI-compatible):
Azure OpenAI (deployment routing + api-version)(shipped -kind = "azure"),AWS Bedrock (SigV4, per-model schemas)(shipped -kind = "bedrock", Converse API + SigV4,crates/providers/src/bedrock/),Google Vertex AI (GCP OAuth, regional endpoints)(shipped -kind = "vertex_ai",crates/providers/src/google/vertex/). Azure: dedicatedResolved (issue #65). The provider config now takes an optionalapi_versionconfig field - a desired fast-follow to the shippedazurekind, which currently reads the version from an?api-version=...query string onbase_url. A first-class field needs a matchingProviderSpec+crates/server/src/config.rschange.api_versionfield (azure-only, rejected on other kinds at boot), threaded throughProviderSpecintoAzureProvider. Precedence: the explicit field wins over an?api-version=...query string onbase_url(kept for back-compat), which wins over the pinned built-in default.Cohere chat (Command R/R+) - we ship Cohere embed+rerank; chat is a distinct schema.Resolved (shipped -crates/providers/src/cohere/chat.rs, text chat + streaming + tools). The remaining slice is vision/image input only: the chat translation maps content withimage_url: None, tracked as issue #73.- More rerankers:
Mixedbread (mxbai-rerank)(shipped -kind = "mixedbread"),Pinecone Rerank(shipped -kind = "pinecone"),NVIDIA NIM rerank(shipped -kind = "nvidia"),Together LlamaRank(shipped -kind = "together"). All four live undercrates/providers/src/; the section’s “cheap differentiation for a first-class rerank gateway” goal is done.
Noted while building M8 (vision / image input)
LM-2004pre-flight is primary-only. A remotehttp(s)image URL is rejected up front (LM-2004, 400) only when the model’s primary provider can’t fetch it (Gemini). If the primary accepts URLs (OpenAI) but a Gemini model is a fallback, a fail-over to Gemini surfaces asLM-3002(502, translation error) rather than a client 4xx - safe (never fetched, no retry loop) but a soft break of the 4xx/5xx separation (rule 8). Options if it ever bites: scan the whole chain in the pre-flight (rejects some primary-servable requests), or add a dedicated client-inputProviderErrormapped to a 4xx.- Per-image token heuristic for the estimation fallback - done (issue #9, see the note above under “M8 (vision - image input to chat)”). The remaining gap is a true dimension-based tile count, which needs image decoding this gateway deliberately does not do on the request path.
Provider-native image URI forms. Anthropic/Gemini file & GCS URI image sources (beyond inline base64 + remote URL) are not modelled.Resolved (issue #12, 0.2.0). See Providers andLM-2008.- Tool-role messages with image parts are silently flattened (noted while
fixing issue #73). The Cohere translator gates its v2 image blocks on the
user role (Cohere’s
ToolMessageV2cannot carry images), so an image part on atoolmessage is flattened to its text - consistent with the Anthropic translator today, but honest handling would be a 400 before the upstream call. Candidate: a shared role-aware content check in the M8 pre-flight instead of per-translator conventions. - Remote image URLs on OpenAI-compatible-path providers whose upstream only
accepts base64 (noted while wiring ollama chat, issue #63). Every kind
served by the shared OpenAI provider inherits
accepts_remote_image_url() = true, includingollama(its/v1chat endpoint takes base64data:URIs only). So a remotehttp(s)image URL on a vision-declared ollama model skips theLM-2004pre-flight and fails opaquely upstream instead of as an honest client 4xx. Candidate fix: gateaccepts_remote_image_url()on the provider kind (or a per-spec flag) rather than on the implementing provider type.
Contributing
LUMEN is open to contributions. The canonical, always-current guide lives at the repository root - GitHub renders it in the pull-request and issue UI:
It covers development setup, the validation bar (cargo test / clippy
pedantic / fmt), the Definition of Done, commit and PR conventions, the ADR
process, and the full issue-label taxonomy.
Issues are classified along four axes (Type, priority:, area:, scope:); the canonical taxonomy with label meanings is in CONTRIBUTING.md.