Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

The five pillars

Every trade-off is decided in this order:

  1. Performance - < 1 ms added latency p99, zero-copy streaming, ~15 MB RAM idle.
  2. Sovereignty - zero telemetry, prompts never logged by default, single binary.
  3. Robustness - propagated cancellation, backpressure, DB off the request path.
  4. Multi-capability - chat + embeddings + rerank + typed decisions are first-class citizens.
  5. 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-Type is irrelevant; the body is treated as the document’s new contents).
  • Send the hash from a prior GET /admin/config as the If-Match header. A missing If-Match header is rejected with 400 (LM-1001) - the request is malformed like any other missing-required-input case.
  • If If-Match does 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 with 412 (LM-1004) and nothing is written. GET /admin/config again to see what changed, then re-apply against the fresh hash.

What happens on a successful apply:

  1. 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 required base_url) is rejected with 400 (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.
  2. 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.
  3. The document is persisted atomically. File mode: the current file is copied to a .bak sibling (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 to config_versions (the newest 50 are kept).
  4. 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

CodeHTTPWhen
LM-2001404The requested model id was not found.
LM-2002400The model exists but does not serve the chat capability.
LM-1001400Malformed or invalid request body.
LM-1002413Request 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 with LM-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 carrying LM-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

ShapeExampleNotes
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 with LM-2005 (400) - pass a data: 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 are score, composite (weighted criteria) and choice, and a static context can be added; see Virtual models. strategy = "noul" is still accepted as an alias of predicate, and lumen config migrate rewrites it.
  • Clients call plain /v1/rerank; ordering, top_n, return_documents, rank_fields and fallbacks (a fallback virtual model whose second target is cohere/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 choice over more documents than the target’s option limit (Ollama 26, otherwise 255) is LM-1001. A document longer than about 4,096 tokens is truncated first, like Cohere’s default max_tokens_per_doc.
  • Refusals: OpenAI can refuse a question. A refused predicate or score question scores that document (or that composite criterion) 0.0; a refused listwise choice fails the attempt as content_filter, so a virtual model with fallback_on = ["content_filter"] moves to its next target. Every refusal is counted in lumen_decision_refusals_total{model}, including those of a choice that 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_tokens is usage.total_tokens (unflagged when the upstream reports it); search units are derived. The decision model’s cost_per_1m_input bills per input token.
  • The remap belongs to a target of a virtual model. For per-tenant relevance rules, put a switch on 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 hasFormat
input, and questions is an arrayOpenAI
state, and questions is an objectTypeSafe (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.

typeAsksinstructionscriteria
noulA yes/no question; answers the probability of yes.required, unless criteria is givenoptional object (what true / false mean)
choicePick one option; answers the choice, its distribution and a confidence.requiredrequired: an object of 1 to 255 options (option id to description, or null)
scoreRate against ordered levels; answers the level with its legend, distribution and a confidence.requiredrequired: 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 typeFields
predicateinstructions (required)
choiceinstructions, choices: 2 to 255 unique {value, description?}; value is a string or a boolean
scoreinstructions, 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): empty questions (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 missing model; in the OpenAI format, an unknown top-level parameter (as OpenAI itself answers), more than 128 images, a choice with fewer than 2 or more than 255 options, a duplicate name; in the TypeSafe format, a null state, a missing or unknown question type, missing instructions (unless a noul has criteria), a malformed criteria. A request that no target in the chain can take is also LM-1001, found before any call (see Cross-vendor fallback).
  • LM-2003 (400): an image sent to a model whose modalities lack "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 a data: URL. Remote http(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 statusLUMENRetried / falls back
401, 422LM-3003 (502), names the provider; the upstream body is not forwardedno
429LM-3001 (429), honouring Retry-Afteryes
529 Overloaded, 5xxa 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 formatWhere the image goes
OpenAIinput as user messages with input_image parts (image_url is a data: URL; at most 128 per request)
TypeSafeinside 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.

RuleWhoError when nothing is left
An image needs "image" in the model’s modalitiesallLM-2003
More images than the target allowsCloudflare (4)LM-1001
A choice with 1 optionOpenAI, OllamaLM-1001
A choice or score with more than 26 options or levelsOllamaLM-1001
More questions than the target allowsPerplexity (128), Cloudflare (64)LM-1001
A noul without instructionstypesafe modelsLM-1001
A choice with a string and a boolean of the same spelling ("true" and true)TypeSafe-family targetsLM-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 usage with a non-zero input_tokens, it is used as is, unflagged. A missing or malformed usage never 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 upstream output_tokens count 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:

Modelcost_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.

  1. Send the same body to /v1/decisions. Nothing else changes in the request or the response: /v1/decisions detects the TypeSafe format and answers in it.
  2. Rewrite your config. Run lumen config migrate (add --dry-run to preview): it renames the capability systemone to decisions and the remap strategy noul to predicate. The old spellings still load, with a boot warning.
  3. Update dashboards. The capability label of every metric and the capability in GET /v1/models change from systemone to decisions. This is a breaking change for queries that filter on the old value. usage_log keeps 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/decisions is request/response only.
  • Jev rate limits (TypeSafe, 2026-09): 64k tokens of context per request (32k for state plus 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 as LM-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 a remap onto a decision model; see Decision models as rerankers. Per-tenant relevance rules use a switch on 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 modelVirtual model
Declared in[[providers.models]][[virtual_models]]
What it isAn upstream binding: id, upstream_id, capabilities, modalities, prices, release_dateA name with logic over one or more targets
Callable by clientsYesYes
Can carry routing logicNoYes

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:

StrategyBehaviourTargets
singleThe one target.Exactly 1
fallbackTargets in order; move to the next when the failure matches fallback_on.2 or more
splitPick 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
switchThe 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:

  1. every model reference exists;
  2. strategy shape (the table above);
  3. every target serves the virtual model’s capability, or carries a remap that makes it compatible (section 7);
  4. no cycles (the error prints the path, for example a -> b -> a);
  5. nesting depth at most 8 virtual models from the requested id down to a foundation model;
  6. at most 64 attempts per request: a fallback or split counts the attempts of every target, a switch only 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);
  7. the contents of overrides, preset and remap.

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.

TriggerMatches
provider_errorRetryable 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_limitedUpstream 429 (LM-3001).
timeoutConnect, first-token and per-attempt timeouts (LM-3005, LM-3011, LM-3012).
circuit_openThe target’s circuit breaker is open.
context_lengthOpt-in. The upstream rejected the input as too long (surfaces as LM-2012 when nothing absorbs it).
content_filterOpt-in. The upstream refused on content policy (surfaces as LM-2013).
  • Default when fallback_on is omitted: ["provider_error", "rate_limited", "timeout", "circuit_open"], exactly the behaviour of the earlier per-model fallbacks.
  • context_length and content_filter rely 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 TypeSafe 401 or 422, which surface as LM-3003 but 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_ms bounds 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:

AttributeTypeSource
groupstringThe authenticated key’s budget group (keys and budgets). Missing when auth is off or the key has no group.
"metadata.<key>"string, number or boolA top-level field of the x-lumen-metadata header (alias cf-aig-metadata, ADR 002).
has_imagesboolA chat or embed request contains an image part.
has_toolsboolThe chat request declares tools.
streamboolThe chat request is streaming.
input_tokensintegerThe 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.
documentsintegerThe 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"] } }]
  • set always applies; default applies only when the client did not send the field; drop removes it.
  • On translated providers (Anthropic, Bedrock, Google, Cohere) a client-sent max_tokens wins over max_completion_tokens. To cap output tokens there with an override, set max_tokens.
  • Overrides act on the parsed request, not on JSON paths, and each capability has an allow-list of fields:
CapabilityAllowed fields
chattemperature, top_p, max_tokens, max_completion_tokens, stop, seed, presence_penalty, frequency_penalty, response_format, reasoning_effort, parallel_tool_calls
embeddimensions, encoding_format
reranktop_n
decisionsnone

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_modeEffect
prepend (default)The preset becomes the first message, before any client system message.
replaceClient system messages (role system or developer) are removed; the preset takes their place.
if_absentApplied only when the client sent no system message (role system or developer).
  • system_prompt is at most 32 KiB and must not be blank. overrides uses 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, so input_tokens switch 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.

strategyFieldsQuestions per requestrelevance_score
predicate (default; alias noul)instructions, criteria.true, criteria.false (all default to a generic relevance question)One predicate per documentThe predicate probability
scoreinstructions, levels (worst first, 2 to 10 entries)One score per documentlevel / (levels - 1)
compositequestions: 1 to 8 entries of { instructions, criteria.true, criteria.false, weight > 0 }N predicates per documentsum(weight_i * p_i) / sum(weight_i)
choiceinstructionsOne choice question; the options are the documentsThe 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, score and composite are 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. For composite the per-call budget counts every question of every document.
  • choice must 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 with LM-1001 (400) naming the limit, before any upstream call. A choice over 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

WhereWhat
x-lumen-model-used response headerThe foundation model that served (unchanged).
x-lumen-route response headerThe path taken, for example acme/chat>acme/chat-eu>mistral-large. Set when a virtual model was requested.
usage_log.model / model_usedThe requested id / the foundation model that served.
usage_log.routeThe 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.
Tracingvirtual_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.

RoutePurpose
GET /admin/config/virtual_modelsList (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}/planThe 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 M on provider P with fallbacks = [F1, F2] becomes the foundation model P/M, and a virtual M is created with strategy = "fallback" and targets = [P/M, F1, F2]. The renamed model keeps sending its old upstream id: the migrator pins upstream_id to the old value when the model had none.
  • Only models with fallbacks are renamed. If P/M already exists, the migrator stops with an error naming it.
  • If M declares several capabilities, one id can carry only one virtual model: the virtual M gets the first declared capability, and the others are printed as needing a decision. Until you add a second virtual model, a call to M for another capability returns LM-2002; it still works through P/M, without fallback. --dry-run shows this before anything is written.
  • A typesafe model R declaring rerank (with or without a converter block) becomes a virtual R with capability = "rerank", strategy = "single", and one target with a predicate remap that copies the converter’s fields, pointing at the provider’s decisions foundation model with the same upstream_id. If none exists, the migrator creates P/<upstream_id> with capabilities = ["decisions"]. When the prices of the removed reranker differ from that model’s, a note asks you to review them.
  • The capability spelling systemone (model capabilities, virtual model capability) becomes decisions, and remap.strategy = "noul" becomes predicate (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

  1. Response body - the OpenAI-compatible usage object on chat and embeddings responses, and usage.search_units plus usage.total_tokens on rerank responses (rerank carries both a search-unit and a token count, each independently flagged - estimated for search_units, tokens_estimated for total_tokens - see Reranking - Billing).
  2. Prometheus - lumen_tokens_total (every capability, including rerank’s token count) and lumen_rerank_search_units_total (rerank’s search-unit count), both carrying an estimated (or upstream-reported) signal. See Metrics & dashboards.
  3. usage_log (when [auth] is enabled) - per-request token counts, cost and the estimated flag, 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) and usage.completion_tokens_details (reasoning_tokens). For Anthropic prompt caching, cache_read_input_tokens maps to cached_tokens (a cache read/hit, same as OpenAI) and cache_creation_input_tokens rides a distinct cache_creation_tokens field in prompt_tokens_details (a cache write, which has no OpenAI equivalent).
  • Prometheus - lumen_token_breakdown_total{capability, model, provider, kind} with kind one of cached, reasoning, cache_write. It is a subset of lumen_tokens_total, split out so cache and reasoning usage can be summed on their own.
  • usage_log - the nullable cached_tokens, reasoning_tokens and cache_write_tokens columns.

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:

  1. Upstream-reported usage. Authoritative and free - already present in the response body or the final SSE chunk. estimated = false.
  2. 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 via spawn_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_total and the usage_log media_count/media_bytes columns, 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

MetricLabelsMeaning
lumen_tokens_totalcapability, model, provider, direction, estimatedTokens processed, cumulative. direction is input/output; estimated is true/false (ADR 003).
lumen_tokens_estimated_totalnoneSubset of the above that was locally estimated rather than upstream-reported.
lumen_token_breakdown_totalcapability, model, provider, kindUpstream-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_totalmodel, providerRerank search units, upstream-reported when available.
lumen_media_totalcapability, model, provider, media_typeMedia items (images, …) processed - a billing dimension alongside tokens (M9).
lumen_media_bytes_totalcapability, model, provider, media_typeDecoded media bytes processed (M9).
lumen_virtual_model_requests_totalvirtual_model, model_usedRequests 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_secondsmethod, path, statusWall 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_secondscapability, model, provider, statusEnd-to-end latency of one accounted API call. For streaming chat this covers the full stream, recorded when accounting closes.
lumen_circuit_stateprovider, modelCircuit-breaker state: 0 closed, 1 open, 2 half-open.
lumen_provider_upproviderBackground health-probe result: 1 up, 0 down (absent = unknown / not probed).
lumen_usage_log_dropped_totalnoneUsage-log entries dropped because the logging channel was full.
lumen_metadata_rejected_totalnonex-lumen-metadata headers dropped as malformed or out of bounds.
lumen_config_reloads_totalnoneSuccessful configuration hot reloads.
lumen_config_reload_failures_totalnoneConfiguration reloads rejected as invalid; the previous config kept serving.
lumen_webhook_queued_totalnoneWebhook 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_totalnoneWebhook events the receiver acknowledged with a 2xx.
lumen_webhook_dropped_totalnoneWebhook events dropped because the queue was full.
lumen_webhook_retries_totalnoneWebhook delivery attempts that failed and were retried with backoff.
lumen_webhook_dead_totalnoneWebhook events abandoned after exhausting their retry budget, or permanently rejected by the receiver.
lumen_webhook_delivery_secondsnoneWall time of a single webhook delivery attempt.
lumen_usage_events_pendingnoneBilling 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_secondsnoneAge of the oldest unacknowledged billing usage event, in seconds (0 when none).
lumen_usage_events_delivered_totalnoneBilling usage events the control plane acknowledged in accepted.
lumen_usage_events_failed_totalreasonBilling 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_totalnoneBilling 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 conditionMeaning
increase(lumen_usage_log_dropped_total[5m]) > 0Usage-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]) > 0A config reload was rejected; the old config keeps serving. The deploy that “went out” did not.
increase(lumen_webhook_dropped_total[5m]) > 0The 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]) > 0Events 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 5mBilling 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]) > 0A 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 2mA provider/model circuit is open: calls are short-circuited to fallbacks (or failing).
lumen_provider_up == 0 for 5mA 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 debug and trace. 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 Debug implementations 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 as key, token or password), then the message is cut to 512 characters.
  • It never reaches the client. The client gets the usual error envelope: LM-2012 or LM-2013 when the body classifies as a context-length or content-policy refusal, LM-3003 otherwise.

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):

ParameterMeaningDefault
sinceWindow start (inclusive): unix seconds or RFC3339.24 hours before until
untilWindow end (inclusive): unix seconds or RFC3339.now
cursorReturn rows with id strictly greater than this.start of the window
limitPage 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

CodeHTTPCause
LM-4001402Hard 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-4002429Requests-per-minute quota exceeded.
LM-4003429Tokens-per-minute quota exceeded.
LM-4004401Missing 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.

MethodPathPurpose
POST/admin/keysCreate a virtual key.
GET/admin/keysList 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}/rotateMint a new secret for an existing key (one-time plaintext, identity and spend preserved).
POST/admin/keys/{id}/grantAtomically add to a key’s budget cap (concurrency-safe top-up).
POST/admin/groupsCreate a budget group - a shared pool member keys draw from.
GET/admin/groupsList 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}/grantAtomically 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}/checkCheck a provider’s live credentials without spending tokens.
GET/admin/usageAggregated 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 402 LM-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_ref on a group (POST/PATCH /admin/groups): the control plane’s account id. A key in a group that has an account_ref is billable.
  • external_ref on 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 (400 LM-1001). Zero and negatives are rejected; overflowing literals like 1e999 never even parse; and a single grant is capped at 1e12 USD so repeated grants can never sum the stored cap toward infinity (which would read back as unlimited).
  • Unknown or deleted id (404 LM-1003), like every other admin write.
  • A capless target (budget_max null, 400 LM-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=true to list tombstones (the audit view);
  • rejects any further PATCH, DELETE or rotate with 404 LM-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:

ValueMeaning
trueThe upstream accepted the credentials.
falseThe upstream rejected them (401/403), or none are configured (then reachable is null: no request was made).
nullThis 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:

KindCheck request
openai, groq, together, fireworks, deepseek, xai, deepinfra, mistral, vllm (with a key)GET {base_url}/models
openrouterGET {base_url}/key (its model list is public, so it would pass any key)
huggingfaceGET https://huggingface.co/api/whoami-v2, or GET {base_url}/models with a custom base_url
cloudflareGET {account}/ai/models/search?per_page=1
anthropicGET {base_url}/v1/models?limit=1
coherePOST {base_url}/v1/check-api-key (reads its valid flag)
googleGET {base_url}/v1beta/models?pageSize=1 (a 400 API_KEY_INVALID counts as rejected)
azureGET {endpoint}/openai/models?api-version=...
pineconeGET {base_url}/indexes
vertex_aiMint a fresh OAuth token from the service account (the cache is bypassed)
bedrockSigV4-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 keyNothing to check: key_valid: null
jina, voyage, mixedbread, typesafe, perplexity, nvidia / tei with a keyNo 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):

ParameterMeaningDefault
key_idOnly rows for this virtual key id.all keys
group_idOnly rows attributed to this budget group id.all rows
modelOnly rows for this client-facing model id.all models
providerOnly rows attributed to this provider instance.all providers
capabilitychat, 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
sinceWindow start (inclusive): unix seconds or RFC3339.until - 24 h
untilWindow end (inclusive): unix seconds or RFC3339.now
group_bymodel, model_used, provider, capability, key_id, group_id, status or total.model
limitMaximum 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_requests counts rows whose numbers are exact, which includes admission refusals (402/429): they consumed zero tokens, and zero is exact. estimated_requests counts 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

EventFires whenWhat a backend does with it
budget.thresholdbudget_spent / budget_max crosses a configured percentageCharge the customer, then grant
budget.exhaustedThe first LM-4001 refusal since the subject last had headroomAlert, upsell, or suspend cleanly
key.disabledA PATCH disabled a key that was enabledMark the key inactive in your registry
key.rotatedPOST /admin/keys/{id}/rotate succeededInvalidate any cached key material
key.deletedDELETE /admin/keys/{id} tombstoned the keyDrop 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:

HeaderMeaning
x-lumen-signatureHex HMAC-SHA256 of the exact request body
x-lumen-event-idUnique per event, not per attempt
x-lumen-eventThe event kind, so you can route without parsing
x-lumen-timestampThe 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

MetricMeaning
lumen_webhook_queued_totalEvents accepted into the queue
lumen_webhook_sent_totalEvents the receiver acknowledged with a 2xx
lumen_webhook_dropped_totalEvents dropped by a full queue - raise channel_capacity, or fix the receiver
lumen_webhook_retries_totalFailed attempts that were retried
lumen_webhook_dead_totalEvents abandoned (retries exhausted, or a permanent rejection)
lumen_webhook_delivery_secondsWall 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_max is 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_ref on the group holds the Lab account id. external_ref on a key holds the Lab key id. Both are opaque text, 1 to 128 characters when set. Set them on POST and PATCH; PATCH with null clears one. Both are returned by the GET routes and filterable on the list routes: GET /admin/groups?account_ref=... and GET /admin/keys?external_ref=....
  • account_ref must be a UUID while [usage_events] is configured, because the Lab rejects any other account id. A violation is 400 LM-1001 naming 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:

  1. POST /admin/groups with account_ref the first time the account enables LUMEN on this gateway.
  2. POST /admin/keys with group_id and external_ref per customer key.
  3. Keep the lease in step with the account’s credits through GET /admin/groups/{id} and PATCH /admin/groups/{id} (or grant), see “The lease sync” below. Admission is unchanged: an exhausted lease answers 402 LM-4001, the correct answer for a customer who is out of credits. See Keys, quotas & budgets.
  4. 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
FieldRequiredDefaultBounds and meaning
urlyes (or LAB_URL)noneThe 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_idyes (or LAB_INSTANCE_ID)noneThe 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_envnoLAB_INSTANCE_SECRETName of the environment variable holding the instance secret, never the secret itself. Non-blank, no surrounding whitespace.
signing_key_envnononeDeprecated alias of secret_env (the ADR 015 name). When set it wins, and boot logs one warning naming secret_env as the replacement.
sourcenothe instance idLabel 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_sizeno500Events 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_msno5000Per-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 inherits LAB_URL from 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_ID without any url refuses to boot with an error naming LAB_URL, and a url without an instance id refuses to boot naming LAB_INSTANCE_ID.
  • The secret must be set. The gateway refuses to boot when the variable named by secret_env (or signing_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 in Debug output.
  • Every live group’s account_ref must 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-UUID account_ref; the error lists the offending group ids and nothing else. To fix it: boot without [usage_events] (and without LAB_URL / LAB_INSTANCE_ID), PATCH /admin/groups/{id} each listed group with its Lab account UUID (or null if it is not a Lab account), then restore the block and restart.
  • With no block (and neither LAB_URL nor LAB_INSTANCE_ID set) there are no outbox rows, no sender task, no lumen_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_pending to reach 0 (stop billable traffic if new events keep arriving), stop the gateway, confirm SELECT COUNT(*) FROM usage_outbox WHERE delivered_ms IS NULL AND dropped_ms IS NULL is 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 401 or 403 (the credentials are wrong: check LAB_INSTANCE_ID and the secret), or answers 2xx with a body that is not this gateway’s identity: a malformed body, an instance_id other than the configured one (compared case-insensitively), a product other than lumen (the Lab would skip every event with product mismatch) or a kind other than hosted.
  • Anything else only warns (any other status, for example a 404 from a Lab that does not serve the endpoint yet or a 5xx, a connect error, a timeout, or a 2xx whose 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, /health included, 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:

RoutesBehaviour
GET /admin/keys, GET /admin/groups, GET /admin/usage, GET /admin/usage/exportOnly 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 group404 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/keysgroup_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/groupsaccount_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, /metrics403 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 still 401 LM-4004.
  • Case does not matter for UUIDs. The header and every stored UUID account_ref are 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_ref a 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:

RouteWhat 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_maxSets 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 amountRaises 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 settle by 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 an account_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-point budget_spent column. 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} with group_id in the body (including null);
  • PATCH /admin/groups/{id} with account_ref in the body (including null).

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"
  }
}
  • id is 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.
  • operation is always gateway: one event aggregates every capability of the key over the window, because LUMEN keeps one watermark per key. (The Lab schema’s enum for lumen also lists chat, embed, rerank and systemone; that list predates the decisions rename of ADR 017, and LUMEN sends gateway only.)
  • units are raw counts the Lab prices: settled requests, input and output tokens, and tokens_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_usd is the settled cost since the watermark, in integer micro-USD, passed through and marked up by the Lab. Never the floating-point budget_spent column, never a reservation.
  • description is key <lumen key id> <window start>..<window end>. occurred_at and 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-Id header. The lease snapshot (group), source, key_id and window that ADR 015 carried in data are gone.
  • The schema’s usageData forbids 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 job lab-contract-drift compares it with meilisearch/lab main and fails on drift when the LAB_REPO_TOKEN secret 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_size pending rows whose retry time has come, oldest first, and sends POST {url}/internal/events with body {"events":[...]}.
  • Headers: Content-Type: application/json, X-Lab-Instance-Id: <LAB_INSTANCE_ID>, X-Lab-Timestamp: <unix seconds> and X-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 2xx response carries {"accepted":["<id>", ...]}. Only ids listed in accepted are 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 waits min(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 401 is logged as an error (check LAB_INSTANCE_ID and 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 accepted is permanently rejected after 24 h (contract v2, section 3.5). The clock starts at the first 2xx answer 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 sets usage_outbox.dropped_ms, logs an error naming the event id and increments lumen_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

MetricMeaning
lumen_usage_events_pendingEvents in the outbox neither acknowledged nor dropped.
lumen_usage_events_oldest_pending_secondsAge of the oldest pending event (0 when none).
lumen_usage_events_delivered_totalEvents 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_totalEvents 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.

  1. 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"
    
  2. 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"
    
  3. Upgrade and start the new build, then mark the parked rows dropped (now in milliseconds), so they leave lumen_usage_events_pending and 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:

  1. Mint the instance id and secret (the hosted-engine rake task, bin/rails lab:hosted_engine:create PRODUCT=lumen ...) and serve GET /internal/instances/me.
  2. Accept product: "lumen" with operation: "gateway" and the four unit names in its own contracts/lab-events.schema.json (the file LUMEN vendors), and price them in saas/config/pricing.yml (LUMEN’s money is provider_cost_micro_usd).
  3. Verify X-Lab-Instance-Id, X-Lab-Timestamp and X-Lab-Signature on POST /internal/events and answer {"accepted": [...]}.
  4. 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.
  5. Set X-Lumen-Account-Ref (the canonical lowercase account UUID) on every proxied /admin/* call and never expose the platform-only routes to users.
  6. Run SyncLumenLeasesJob against GET /admin/groups/{id} and PATCH /admin/groups/{id} (or grant), 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 itselfauth.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.

MethodPathPurpose
GET/admin/configThe 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/configReplace the whole dynamic document.
GET/admin/config/providersList 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_modelsList 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}/planThe 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-Match is 400 LM-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) is 412 LM-1004. Re-GET and 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_source itself) is 400 LM-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 400 LM-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 400 LM-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

  1. GET /admin/config against the running file-mode process and save the response’s config field (the response is a JSON {"config", "hash"} envelope; the TOML document is the config string, not the raw body):

    curl -s http://localhost:8080/admin/config \
      -H "Authorization: Bearer $LUMEN_MASTER_KEY" | jq -r .config > saved.toml
    

    In 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.

  2. Edit the boot TOML: set config_source = "db", and strip every dynamic-layer key ([[providers]], [[virtual_models]], [resilience], [tokenizer], [image_fetch], [webhooks], and auth.flush_interval_ms / auth.retention_days) - the boot file must contain only boot-layer keys once db mode is selected. Confirm auth.enabled = true.

  3. Restart. The process boots with an empty stored document (first boot in db mode, see above) - /health is healthy, /v1/* answers LM-2001 until the next step.

  4. 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). Keep auth.flush_interval_ms and auth.retention_days if the file set them. The saved document is the file-mode GET’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 refused 400 LM-1001.

  5. PUT /admin/config with the stripped document from step 4, using the empty document’s hash as If-Match (the GET /admin/config a fresh db-mode boot returns before any write).

DB to file

  1. GET /admin/config against the running db-mode process and save the response’s config field (jq -r .config, as in the file-to-DB step 1) - the dynamic document alone, no boot keys.
  2. 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.
  3. Set config_source = "file" (or remove the key - "file" is the default) in that same file.
  4. 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

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

TimeoutCodeWhere configured
ConnectLM-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-tokenLM-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.
TotalLM-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: nosniff
  • X-Frame-Options: DENY
  • Referrer-Policy: no-referrer
  • Content-Security-Policy: default-src 'none'

Surface control

  • Restrict /admin/* and /metrics at the network layer (firewall, reverse proxy, service mesh) as appropriate for your deployment. /admin/* requires the master key, but /metrics is unauthenticated by design - see SECURITY.md.
  • GET /health is 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/webhooks must be public. Internal hosts (loopback, private, link-local, localhost, or names that resolve to them) are refused unless listed in LUMEN_WEBHOOK_ALLOWED_HOSTS (comma-separated hosts, .suffix for a domain and its subdomains, read at boot). Set it when the receiver runs next to the gateway, for example LUMEN_WEBHOOK_ALLOWED_HOSTS=lab in 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:

SignalEffect
SIGTERM / SIGINTGraceful shutdown: stop accepting, drain in-flight requests (SSE streams included) for up to 30 seconds, then exit.
SIGHUPConfig 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 SIGTERM attempts a final flush, see above), then copy lumen.db together with its -wal and -shm sidecar 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_log history 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

  1. 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).
  2. Back up the database if auth is enabled - see Backups. Upgrades run schema migrations automatically, and the backup is your downgrade path.
  3. 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 = false there 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-xxxx error 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:

  1. Fetches three public catalogs: models.dev, LiteLLM’s model_prices_and_context_window.json, and OpenRouter’s /api/v1/models plus /api/v1/embeddings/models. LiteLLM’s openai_like/providers.json is read as a hint for which of its providers speak the OpenAI format.
  2. Diffs them against the committed snapshot, scripts/model-watch/snapshot.json: per source, provider id to model id to capability (chat, embedding or rerank; image, audio and video models are ignored).
  3. Classifies every addition against what LUMEN supports, read at run time from crates/providers/src/kind.rs (the ProviderKind variants, is_openai_compatible, default_base_url, supports_embeddings) and the capability tables in docs/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 ProviderKind variant 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.
  4. 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: medium when it brings embedding or rerank models (otherwise priority: low), and the matching scope: 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.
  5. 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 wontfix to 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_ai for together), add the id to scripts/model-watch/providers.json. Ids are normalized (lowercase, separators removed) and a provider whose base URL equals a kind’s default_base_url is matched automatically. A capability only described in prose in docs/providers.md (Together and Cloudflare rerank) goes under extra_capabilities there.
  • A new ProviderKind is picked up by the next run with no watcher change, as long as it is also added to the docs/providers.md tables (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.rs fails 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 = true the gateway serves the same document as JSON at GET /openapi.json (master key), which is how the Meilisearch Lab vendors it. Like the other platform routes it answers 403 LM-4005 to a call scoped with X-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_env names 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_date is metadata only. An ISO date ("2024-08-06", or the unquoted TOML date 2024-08-06), validated at load. GET /v1/models returns it as release_date and as the OpenAI integer created (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).
  • capabilities must match the kind. A model can only declare capabilities its provider kind implements (table below); this is validated at boot.
  • base_url is 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. input items may be strings or arrays of parts ({"type":"text",...} / {"type":"image_url",...}). Images are passed as data: URIs, or - with [image_fetch] enabled - as remote http(s) URLs the gateway fetches under SSRF/resource guards and inlines. Image input to a model without "image" is rejected with LM-2003; a remote URL with fetching disabled is LM-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.
kindChatEmbedRerankDecisionsapi_key_envbase_urlEmbed batch limit
openai✅✅✅requiredoptional2048
mistral✅✅requiredoptional512
anthropic✅requiredoptional-
google✅✅requiredoptional100
vertex_ai✅✅required (SA JSON)required (GCP region)1
bedrock✅✅AWS SigV4optional1
cohere✅✅✅requiredoptional96
jina✅✅requiredoptional2048
voyage✅✅requiredoptional128
mixedbread✅requiredoptional-
pinecone✅requiredoptional-
nvidia✅keylessrequired-
tei✅✅keylessrequired32
ollama✅✅✅keylessrequired512
azure✅✅requiredrequired2048
typesafe✅✅requiredoptional-

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:

kindChatEmbedapi_key_envbase_urlDefault base URL
groq✅norequiredoptionalhttps://api.groq.com/openai/v1
together✅✅requiredoptionalhttps://api.together.xyz/v1
fireworks✅✅requiredoptionalhttps://api.fireworks.ai/inference/v1
deepseek✅norequiredoptionalhttps://api.deepseek.com/v1
openrouter✅✅requiredoptionalhttps://openrouter.ai/api/v1
perplexity✅norequiredoptionalhttps://api.perplexity.ai
xai✅norequiredoptionalhttps://api.x.ai/v1
deepinfra✅✅requiredoptionalhttps://api.deepinfra.com/v1/openai
huggingface✅✅requiredoptionalhttps://router.huggingface.co/v1
cloudflare✅✅requiredrequired- (URL embeds your account id)
vllm✅✅keylessrequired- (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-luna on POST {base_url}/decisions (public beta). A choice needs at least 2 options and a request at most 128 images. Set cost_per_1m_input = 0.10 and modalities = ["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 the x-api-key / anthropic-version headers, 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: false maps to tool_choice.disable_parallel_tool_use; response_format, seed, logprobs, frequency_penalty and presence_penalty have no Messages API equivalent - see chat extras and the strict flag.
  • 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"]

google

  • kind: google · capabilities: chat, embed (Gemini).
  • Auth: api_key_env (e.g. GEMINI_API_KEY). The key rides the x-goog-api-key header, never the URL.
  • Translation: OpenAI ⇄ Gemini, including streaming (streamGenerateContent). response_format JSON mode maps to generationConfig.responseMimeType / responseSchema, seed to generationConfig.seed, and frequency_penalty / presence_penalty to generationConfig.frequencyPenalty / presencePenalty; logprobs and parallel_tool_calls have no mapping - see chat extras.
  • Embeddings: served through models/{model}:batchEmbedContents (gemini-embedding-001, text-embedding-004, …). One inner request per input; the OpenAI dimensions field maps to outputDimensionality. The API is text-only: pre-tokenized token-id arrays and image content parts are rejected with LM-1001 before any upstream call. Usage follows ADR 003: usageMetadata.promptTokenCount is reported when the upstream returns it, otherwise the gateway derives a local estimate marked estimated.
  • Embed batch limit: 100 inputs per upstream call (Gemini’s documented batchEmbedContents ceiling); 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 and text-embedding-* models on Google Cloud Vertex AI). Distinct from google, which is the public Gemini Developer API: Vertex uses regional endpoints and GCP OAuth instead of a static API key.
  • Auth: api_key_env names 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’s token_uri for a short-lived OAuth2 access token (scope cloud-platform), and sends it as a Bearer header. 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 all Debug output 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=sse when streaming).
  • Project id: taken from the service-account JSON’s project_id.
  • Translation: chat is identical to google (same GenerateContent wire schema), including streaming. Like Gemini, only inline base64 image data is accepted; remote image URLs are rejected with LM-2004.
  • Embeddings: Vertex does NOT expose batchEmbedContents; embeddings go through the prediction API on the same regional, project-scoped path: .../publishers/google/models/{model}:predict with instances[].content. The OpenAI dimensions field maps to parameters.outputDimensionality. Text-only: token-id arrays and image parts are rejected with LM-1001. Usage follows ADR 003: per-input statistics.token_count values are summed and reported as upstream usage; when absent the gateway derives a local estimate marked estimated.
  • Embed batch limit: 1 input per upstream call. gemini-embedding-001 accepts a single instance per :predict request (other text-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 optionally AWS_SESSION_TOKEN for temporary credentials) on every request, so values updated in the process environment (or a config hot reload) take effect without a restart. api_key_env is optional and, if set, overrides only the secret access key. The secret and session token are never logged or shown in Debug.
  • 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}/converse and /converse-stream), which gives one uniform schema across the Anthropic, Meta Llama, Amazon Titan/Nova, Mistral and Cohere model families. The legacy per-model InvokeModel chat 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:0 and predecessors): embeds ONE text per call ({ "inputText": ... }); the gateway loops one signed request per input and reassembles the batch in order. Titan v2 honors dimensions (mapped to Titan’s dimensions/normalize); older Titan models do not, so dimensions is dropped (or rejected under strict).
    • Cohere Embed on Bedrock (cohere.embed-english-v3, cohere.embed-multilingual-v3): embeds a batch in one call ({ "texts": [...], "input_type": ... }); input_type defaults to search_document and honors an override. Cohere does not accept dimensions.
    • 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 inputTextTokenCount and Cohere’s x-amzn-bedrock-input-token-count response header are reported as upstream usage; when absent the request edge derives the local estimate (never a silent zero).
  • Region / base_url: set base_url to 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 a vpce-…-prefixed DNS name) are recognised too. For any other custom endpoint the region comes from AWS_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 for response_format, seed, logprobs, parallel_tool_calls, frequency_penalty or presence_penalty
  • 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 in messages, no top-level system hoist like Anthropic; tool_calls are already OpenAI-shaped), so translation is closer to identity than Anthropic’s. tool_choice collapses to Cohere’s REQUIRED/NONE (forcing one specific named tool has no v2 equivalent and falls back to auto). response_format, seed, frequency_penalty and presence_penalty map onto Cohere’s native fields; logprobs and parallel_tool_calls do not - see chat extras. Usage prefers usage.tokens (actual counts) over usage.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). Declare modalities = ["text", "image"] on a vision model (Command-A-Vision) to opt in. Both inline data: URIs and remote http(s) URLs are forwarded (Cohere fetches remote URLs itself, so LM-2004 does not apply); the optional detail hint (low/high/auto) passes through untouched. Provider-native references (anthropic-file:, gs://, Gemini Files API URIs) are rejected pre-flight with LM-2008.
  • Embed batch limit: 96.
  • Cost: rerank is billed in search units (cost_per_1k_searches).
  • input_type override: Cohere’s embed v2 API requires an input_type and the gateway cannot know query-vs-document intent, so it defaults to search_document (the indexing case). Set input_type as an extra field on the /v1/embeddings request 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 with LM-1001 before 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, not rerank) and renames the request fields (input instead of documents, top_k instead of top_n) with results nested under data; the gateway translates transparently.
  • Usage: billed in tokens, so the gateway reports an estimated token 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 the Api-Key header (not a bearer token), alongside a pinned X-Pinecone-API-Version header the inference API requires.
  • base_url: optional; defaults to https://api.pinecone.io.
  • Schema note: documents are sent as { "text": ... } objects; only the default text rank field is used (rank_fields selection is out of scope for v1).
  • Usage: Pinecone reports usage.rerank_units, carried through verbatim as the response’s search_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 on POST /v1/decisions; see Decisions and ADR 017). Reranking goes through a virtual model remap (Decision models as rerankers).
  • Auth: api_key_env (e.g. TYPESAFE_API_KEY), sent as a bearer token. Never logged, redacted from Debug.
  • base_url: optional; defaults to https://api.typesafe.ai. An override is the API root. Without decisions_path a trailing /v1 is tolerated and the gateway posts to {base}/v1/systemone.
  • decisions_path (optional, typesafe only): the upstream path appended to base_url verbatim. Must start with /. Default /v1/systemone. This and the next field make typesafe the generic TypeSafe-format kind.
  • forward_unknown_fields (optional, typesafe only, default true): forward unknown top-level request fields upstream, as TypeSafe accepts them. Set false for a vendor that rejects them (Perplexity answers 400). Setting either field on another kind is refused at config load.
  • Schema note: the request and response are TypeSafe’s own, forwarded verbatim for a TypeSafe-format client (only model is rewritten to the upstream_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. A noul question without instructions is not sent to a typesafe model (Jev requires them): the target is skipped.
  • Models: jev-latest (stable alias), jev-preview (preview alias) and versioned ids such as jev-1.13.0, mapped through upstream_id.
  • Errors: TypeSafe 401 / 422 surface as LM-3003 (502, not retried, upstream body not forwarded); 429 is LM-3001 honouring Retry-After; 529 Overloaded is a retryable 5xx, so retries apply, and a virtual model’s provider_error fallback trigger matches it (not rate_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.042 and no output price.
  • Usage: upstream usage.input_tokens / output_tokens when reported; otherwise an estimated input count over state plus every question (ADR 003), output 0.
  • Upstream limits (TypeSafe, 2026-09): 64k tokens of context per request (32k for state plus 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).

kindEndpointImage placementLimitsPrice per 1M input (output free)
typesafe{base_url} (default https://api.typesafe.ai) + decisions_path (default /v1/systemone)nonea noul needs instructionsJev $0.042
perplexity{base_url} (default https://api.perplexity.ai) + /v1/decisionsinside state as image_url parts128 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) + /decisionsOpenAI input_imagechoice needs at least 2 options; at most 128 imagesgpt-6-luna $0.10
ollama{base_url} + /v1/systemone (Ollama 0.35+)top-level images, raw base64choice and score take 2 to 26 options or levels; images only on clef and clef-flash0 (local)
cloudflareWorkers AI {account root}/ai/run/@cf/cloudflare/{upstream_id}, with upstream_id clef or clef-flashtop-level images64 questions, 4 imagesClef $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:8000 or the NVIDIA-hosted ranking endpoint root). The gateway posts to {base}/v1/ranking.
  • Schema note: the request nests query: { text } and passages: [{ text }]; there is no top_n on the wire, so the gateway requests the full ranking and truncates to top_n afterwards (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 estimated token 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_id is 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+, on POST {base_url}/v1/systemone; choice and score take 2 to 26 options or levels, and only clef and clef-flash accept images; see Local decision models with Ollama).
  • Auth: keyless.
  • base_url: required - points at your Ollama server root (no /v1). Embeddings use Ollama’s native POST /api/embed; chat goes through Ollama’s OpenAI-compatible endpoint, which lives under /v1 on the same root - the gateway appends the /v1 itself, so keep base_url as the bare server root either way.
  • Chat: served by the shared OpenAI-compatible path - streaming (SSE passthrough), cancellation, and token accounting (upstream usage when Ollama reports it, otherwise a local count marked estimated, ADR 003) all work exactly as for the openai kind.
  • api_key asymmetry: if you set an api_key_env on 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 /v1 with 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_ms on the provider block (see config.example.toml). A self-hosted box on a slow link can also override connect_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 from openai.
  • Auth: api_key_env, sent as the api-key header (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-version query parameter on every request (issue #65). For back-compat the older form still works: append ?api-version=YYYY-MM-DD to base_url. Precedence: the explicit api_version field wins over a base_url query string, which wins over LUMEN’s pinned built-in default (see the azure module doc comment for the exact value). Any query parameters on base_url other than api-version are ignored when building request URLs. api_version is azure-only: setting it on any other kind is rejected at boot.
  • Deployment routing: Azure routes by URL path (/openai/deployments/{deployment}/...), not by the model field in the body. Set each model’s upstream_id to the Azure deployment name - the same upstream_id mechanism 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):

KindWhat the upstream receives
openai (built-in URL or api.openai.com)developer, unchanged
anthropic, bedrockhoisted into the top-level system
google, vertex_aia systemInstruction part
coherean 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:

Fieldanthropicgoogle / vertex_aibedrockcohere
response_formatunsupportedmapped¹unsupportedmapped²
seedunsupportedmapped (generationConfig.seed)unsupportedmapped
logprobsunsupportedunsupportedunsupportedunsupported³
top_logprobsunsupportedunsupportedunsupportedunsupported³
logit_biasunsupportedunsupportedunsupportedunsupported
parallel_tool_callsmapped⁴unsupportedunsupportedunsupported
frequency_penaltyunsupportedmapped (generationConfig.frequencyPenalty)unsupportedmapped
presence_penaltyunsupportedmapped (generationConfig.presencePenalty)unsupportedmapped

¹ {"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 a debug-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 familydata: (inline base64)http(s) URL
OpenAI-family (openai + the OpenAI-compatible kinds) and vllmforwarded verbatimforwarded verbatim
azureforwarded verbatim (OpenAI wire schema)forwarded verbatim
anthropictranslated to a base64 image source blocktranslated to a url image source block (Anthropic fetches it)
coheretranslated 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_datarejected - LM-2004
vertex_aitranslated to inline_data (same as google)rejected - LM-2004
bedrocktranslated 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 urlTranslated forBecomes
anthropic-file:<file_id>anthropicsource: {type: "file", file_id} (Anthropic Files API)
https://generativelanguage.googleapis.com/... (Gemini Files API URI)googlefileData.fileUri
gs://bucket/object (Cloud Storage URI)googlefileData.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 a gs:// URI to Gemini verbatim, but the Gemini Developer API (generativelanguage.googleapis.com, the default base_url of the google kind) documents fileData.fileUri for its own Files API URIs; Cloud Storage gs:// URIs are a Vertex AI capability. Against the default endpoint a gs:// reference is passed through and will be rejected by the upstream (surfacing as an upstream error naming google). It is still parsed and forwarded because the reference form is Gemini-native (mismatch routing stays an honest LM-2008), base_url may 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 only label, last4 and 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 fallback is on (default off); otherwise it fails with LM-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_state gauge 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

CodeHTTPMeaning
LM-1001400Malformed 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-1002413Request body exceeded the configured size limit.
LM-1003404No 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-1004412The 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

CodeHTTPMeaning
LM-2001404The requested model id (foundation or virtual) was not found.
LM-2002400The model (foundation or virtual) exists but does not serve the requested capability.
LM-2003400An 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-2004400A 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-2005400A 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-2006400A 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-2007502A permitted image fetch failed at the remote host (network error, timeout, or error status). type: upstream_error (M9).
LM-2008400A 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-2010400A rerank request supplied no documents to score.
LM-2011400A 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-20124xxThe 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-20134xxThe 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.

CodeHTTPMeaning
LM-3001429An upstream provider rate limited the request.
LM-3002502An upstream provider returned an unparseable/malformed response.
LM-3003502An 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-3004503No healthy upstream available (circuit open / fallbacks spent).
LM-3005504An upstream provider timed out.
LM-3010502An upstream stream ended prematurely (no terminator).
LM-3011504An upstream produced no first token within the first-token deadline.
LM-3012504The connection to an upstream could not be established within the connect timeout.
LM-3013504The whole request (all retries + fallbacks) exceeded the total timeout.
LM-3020503The provider’s circuit breaker is open and no fallback remained.
LM-3021429Every 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 a Retry-After equal 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 for max(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 its rate_limited trigger, 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.

CodeHTTPMeaning
LM-4001402A 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-4002429The key’s requests-per-minute quota was exceeded.
LM-4003429The key’s tokens-per-minute quota was exceeded.
LM-4004401Missing or invalid virtual key. Deliberately does not say why (unknown, disabled and expired are indistinguishable) so callers cannot probe key state.
LM-4005403The 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

CodeHTTPMeaning
LM-5001500Internal 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

CodeHTTPMeaning
LM-6001499The 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:

  1. 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.

  2. 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 (see bench/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).

  3. 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 a stream: true response 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

MachineApple Silicon (arm64), macOS
Toolchainrustc 1.97.0, release profile (lto = "thin", codegen-units = 1)
Commandcargo 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)

BenchMedian95 % CI
executor_overhead_chat (executor around an instant provider)1.21 µs1.04 – 1.40 µs
json_request_deserialize (parse a chat request)1.34 µs1.15 – 1.55 µs
json_response_serialize (serialize a chat response)0.60 µs0.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 µs3.1 – 3.6 µs
systemone_request_pipeline_large (same, at the documented maximum: 128 KB state, 300 questions)~280 µs241 – 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.

BenchMedian95 % CIBefore the final fixes
decisions_passthrough_typesafe_small (1 KB, 3 questions)3.23 µs3.22 – 3.23 µs3.48 µs
decisions_passthrough_typesafe_large (128 KB, 300 questions)280.0 µs279.6 – 280.4 µs307.1 µs
decisions_passthrough_openai_small2.87 µs2.86 – 2.90 µs3.00 µs
decisions_passthrough_openai_large152.5 µs152.2 – 152.9 µs155.8 µs
decisions_cross_vendor_typesafe_small (TypeSafe in, OpenAI out)4.56 µs4.55 – 4.56 µs4.62 µs
decisions_cross_vendor_typesafe_large417.2 µs416.8 – 417.6 µs414.3 µs
decisions_cross_vendor_openai_small (OpenAI in, TypeSafe out)2.80 µs2.77 – 2.86 µs3.10 µs
decisions_cross_vendor_openai_large149.8 µs149.5 – 150.1 µs169.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.

BenchMedian95 % CI
direct_to_upstream (client → mock, no gateway)71.6 µs70.6 – 71.9 µs
via_gateway (client → full LUMEN stack → same mock)168.4 µs158.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.

BenchMedian95 % CI
admit_settle_key (one key with a budget)36.4 ns36.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 ns54.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 ns53.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/.

Targetp50p95p99req/s
direct (mock, no gateway)6.91 ms204.99 ms836.43 ms1191.7
lumen9.44 ms36.57 ms220.57 ms2733.5
litellm v1.92.0323.75 ms656.67 ms6490.29 ms111.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):

TargetTTFB p50TTFB p95TTFB p99
direct (mock, no gateway)6.81 ms204.91 ms836.42 ms
lumen9.38 ms36.34 ms220.17 ms
litellm v1.92.0323.71 ms655.39 ms6490.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

#TargetStatus
1< 1 ms added p99 off-networkMet (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 loadMet. 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).
3throughput ≥ 95 % of directNot 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 the usage_log records, 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.

  1. Header. Canonical x-lumen-metadata; also accept cf-aig-metadata as 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).

  2. Bounds. At most 16 keys; key ≤ 64 bytes; value ≤ 256 bytes; whole header ≤ 4 KiB. These keep log records and memory bounded.

  3. Two sinks, different rules.

    • Logs / usage_log: the full (bounded) object is attached to the request’s structured log fields and stored in a metadata column on usage_log for 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.
  4. Never fails the request. Missing, malformed, oversized or wrong-typed metadata is dropped with a debug!/warn! and a metadata_rejected_total counter increment; the call proceeds normally.

  5. 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.metadata column, the batched writer, and the Prometheus label wiring in telemetry). A thin extractor in server reads 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 /embed is a bare vector array) - so a naive gateway shows 0 tokens 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

  1. Upstream-reported usage - authoritative and free (already in the response body / final SSE chunk). Always preferred. estimated = false.
  2. 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 via spawn_blocking so 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) and output_tokens (completion).
  • Embeddings: input_tokens.
  • Rerank: search_units when the provider reports them, plus a token count of query + documents for uniform observability (estimated when derived).

Three surfaces

  1. Response body - OpenAI-compatible usage (chat/embeddings) unchanged.
  2. Prometheus - cumulative counters, low fixed cardinality: lumen_tokens_total{capability, model, provider, direction, estimated} and lumen_rerank_search_units_total{model, provider}. Optional metadata-allowlist labels come from ADR 002 (never client-unbounded).
  3. usage_log - per-request tokens_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_total counter 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) sums MessageContent::text() per message (the concatenation of text parts) plus, for every image_url part, a flat per-image token constant chosen from the part’s detail hint: "low" -> 85 tokens (OpenAI’s exact, resolution-independent low-detail cost - no dimensions needed to reproduce it); "high"/"auto"/unset -> 765 tokens, an approximation of OpenAI’s 85 + 170 * tiles tile 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 a data: 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 flat 765-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 (default heuristic). 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_base for gpt-4 / gpt-3.5 / text-embedding-3, o200k_base for gpt-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 usage field always carries the cheap heuristic (flagged estimated) - 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 via spawn_blocking (never on a tokio worker, repo rule 2) and finishes the record. The accurate number therefore surfaces in Prometheus and usage_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 and usage_log.latency_ms are 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_cancel too.

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 Sse type; it writes a raw Bytes body with content-type: text/event-stream. SSE heartbeats move to an explicit injected frame rather than axum’s KeepAlive.
  • core gains a bytes dependency (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:

  1. Where the machinery lives, given three capability traits. ChatProvider, EmbeddingProvider and RerankProvider have different signatures, so a single concrete executor cannot call all three. Duplicating retry/breaker logic per capability is unacceptable.
  2. 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.
  3. 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::Client setting. 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 with connect_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(...) to execute_unary(chain, ..., |i| chain[i].provider.chat(...)); the resilience policy is uniform across all three capabilities and both streaming modes.
  • router gains a dependency on telemetry (gauge) and on tokio/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_log gains a model_used column (migration 0002) so a fallback is observable after the fact, mirroring the x-lumen-model-used header.
  • 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 single Bytes, moved not copied) and the reconstructed stream is handed to the existing to_event_stream guards. 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 returns Err, so the executor retries, falls over to the next link, and charges the circuit breaker identically to an open failure. A None is surfaced as the new ProviderError::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 returns ProviderError::Cancelled and drops the stream, aborting the upstream. Cancelled is 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_token timeout, so a silent upstream (headers, then no bytes) trips FirstTokenTimeout and 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 settles usage_log.status and the lumen_request_duration_seconds sample at 499 instead of a fake 200. 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 the CancellationToken (crates/providers/src/http.rs) yields ProviderError::Cancelled, which the stream wrapper maps through GatewayError::from_provider into a terminal SSE error frame. That frame now carries LM-6001 / client_cancelled and settles the sample at 499 - previously LM-5001 / internal / 500. This was the one path that genuinely inflated status="500" samples, and alert rules built on status=~"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 type field gains a fourth possible value, client_cancelled. This is additive; docs/errors.md is 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):

  1. TPM debited the pre-call estimate and never adjusted it. A chat request with max_tokens = 2048 but 40 real output tokens permanently burned 2048 tokens of the per-minute window, starving later requests.
  2. A request rejected by a later admission step still counted toward the earlier quotas. admit bumps 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.
  3. The usage log recorded successful requests only. A refusal at admission (402/429) errored out of Accounting::begin before any UsageRecord was built, so per-key rejection analytics were impossible.
  4. 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_tokens reservations 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::settle gained a parameter; all call sites pass the real total token count (tokens_in + tokens_out).
  • usage_log.metadata may 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):

  1. The [auth] operational knobs (budget-flush cadence, usage-log retention, usage-writer channel sizing) were read once and baked into background tasks.
  2. A provider key stored in the encrypted DB via PUT /admin/provider-keys only 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:

  1. 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.
  2. 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 registry ArcSwap atomically.

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-keys then 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: ProviderKeySource holds a redacted MasterKey (zeroized on drop) and the backfill map carries raw keys only in memory, merged into ProviderSpec whose Debug already redacts api_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_groups alongside drain_dirty) into persist_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_ms of 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 TEXT plus 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=true for 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 keeps usage_log attribution, mirroring key deletion, and its final spend is flushed on removal from memory.
  • POST /admin/keys accepts group_id; PATCH /admin/keys/{id} accepts group_id as 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. A group_id naming an unknown or deleted group is refused (400, LM-1001) before any write.
  • lumen keys create gains --group-id (validated against the DB); a lumen groups offline subcommand is deferred - groups have no bootstrap chicken-and-egg problem because /admin/groups needs 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 ArcSwapOption load 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.
  • VirtualKeyRecord grows group_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: live Arc holders keep enforcing against the detached pool, and after a reload the dangling group_id resolves to no-pool-enforcement with a warning. Admin mutations are expected to be serialized by the control plane; wrapping count+tombstone (and validate+write) in BEGIN IMMEDIATE transactions is the cheap hardening if that assumption ever breaks.
  • Not in this slice (recorded in docs/backlog.md): group RPM/TPM, a group disabled flag, group expiry, nested groups, a lumen_group_budget_remaining gauge, and a lumen groups CLI.

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_log has 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_total becomes 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.md lists “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}/grant and POST /admin/groups/{id}/grant apply atomic budget top-ups (ADR 009); PATCH adjusts limits and enable/disable.
  • Reconciliation (pull, backend from gateway): GET /admin/usage/export streams 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_ms of 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’s budget_spent / budget_max crossed 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. A grant that drops consumption back under a threshold re-arms it (a new epoch begins when budget_max changes).
  • budget.exhausted: the first admission refused with LM-4001 for 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_total increments. 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, grant top-up, all before the customer sees a 402. Today’s alternative is polling GET /admin/usage on 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:

  1. A stored row with enabled = 1 wins outright.
  2. A stored row with enabled = 0 means off, whatever the file says - so DELETE is not silently undone by the next reload.
  3. 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:

  1. The variable named by signing_key_env, when set and non-empty.
  2. The stored secret.
  3. Neither: deliveries are unsigned. Refused as an error when signing_key_env was 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/config emits 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-1004 widen their documented meanings in docs/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

  1. A fourth first-class capability, systemone. Capability::SystemOne serializes as "systemone" everywhere a capability string appears (capabilities = [...] in config, GET /v1/models, the Prometheus capability label, 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.

  2. 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, a usage.estimated: true flag (ADR 003). The TypeSafe SDKs read TYPESAFE_BASE_URL, so pointing it at LUMEN (and TYPESAFE_API_KEY at a LUMEN virtual key) makes an existing Jev client go through the gateway with no code change. GET /v1/models keeps LUMEN’s OpenAI shape, so the SDKs’ models.list() is the one call that does not work through the gateway; documented, not papered over.

  3. Validation at the edge, answers passed through. LUMEN forwards a non-2xx upstream as LM-3003 without its body (secrets and upstream detail never reach the client, CLAUDE.md rule 8), which would turn every TypeSafe 422 into an opaque 502. So the gateway validates the documented request contract itself and answers LM-1001 with a precise message: non-empty model, a state, a non-empty questions map (LM-2011 when empty, the SystemOne twin of LM-2010), each question’s type one of noul / choice / score with non-null instructions, choice criteria a non-empty map of at most 255 options, score criteria 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 question type is rejected: a new TypeSafe question type is a LUMEN upgrade, not a silent passthrough. instructions, criteria entries and state stay 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 an Arc, so a retry or fallback attempt costs a refcount, not a copy of the state.

  4. SystemOneProvider trait, typesafe provider kind. One method, evaluate(req, cancel), with the usual CancellationToken contract. The typesafe kind defaults its base URL to https://api.typesafe.ai (overridable), authenticates with a bearer key, and maps 429 / 529 through the shared status classifier (529 is a retryable 5xx, so the executor retries and falls back as for any overloaded upstream; a Retry-After on a 429 is honoured). Fallback chains, per-model timeouts, retries and circuit breakers apply unchanged, e.g. a pinned jev-1.13.0 falling back to jev-latest.

  5. Token accounting (ADR 003). Upstream usage.input_tokens and output_tokens win when reported. usage is 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 marks usage optional) the gateway estimates input as the byte heuristic over the raw JSON text of state plus every question, which is what TypeSafe ingests, and flags it estimated; 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 existing cost_per_1m_input / cost_per_1m_output model prices (Jev: 0.042 / 0), so hard budgets bite with no new pricing field.

  6. Privacy. state, questions and answers are request content: never logged, never in usage_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 a typesafe model. It is the remap of a target of a rerank virtual model, which also adds the score, composite and choice strategies. 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>}; one noul question per document, id = the document index, with structured instructions {"document": <text>, "question": <converter instructions>} and the converter’s criteria.true / criteria.false. relevance_score is 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_tokens is rerank usage.total_tokens (if any call omits usage, the whole count falls back to the ADR 003 estimate). Rerank cost becomes search-unit price plus cost_per_1m_input on 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:

  1. One place for routing logic. Foundation models become pure upstream bindings.
  2. Zero measurable cost for requests that use no transform: the routing decision stays well under 1 us.
  3. Jev as a first-class reranker, including per-tenant relevance rules (via switch on the budget group) and a cross-capability fallback to a classic reranker.
  4. Every served request still reports tokens and cost against the foundation model that actually served it (ADR 003).

Decision

  1. 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 or config_versions row, 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.

  2. 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 makes GET /v1/models truthful. The strategy is one of:

    • single: the one target.
    • fallback: targets in order; move on when the failure matches fallback_on.
    • split: pick one target at random by integer weight; on a matching failure try the rest in descending weight order (ties keep declaration order). No stickiness.
    • switch: the first target whose when matches 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 (single has 1 target, fallback and split at least 2, switch has when on every target but the last and none on the last, when only under switch, weight only under split, fallback_on only on fallback/split, preset only on chat); capability compatibility (or a remap); 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: a fallback or split sums its targets, a switch takes 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 of overrides, preset and remap. Validation runs at boot, reload and on every admin write. listed = false hides a virtual model from GET /v1/models while it stays callable.

  3. 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-in context_length and content_filter. A TypeSafe 529 is a retryable 5xx (ADR 013), so it matches provider_error, not rate_limited. The default when omitted is ["provider_error", "rate_limited", "timeout", "circuit_open"], exactly the ADR 005 behaviour. context_length and content_filter need each provider to classify its upstream error into a new ProviderError variant; 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_ms deadline bounds the whole tree.

  4. switch conditions (when). Attributes: group (the key’s budget group, ADR 009), metadata.<key> (from x-lumen-metadata, ADR 002), and request facts has_images, has_tools, stream, input_tokens (the ADR 003 estimate, computed lazily only when a rule references it) and documents (rerank). Not the virtual key id. Operators: bare value (equality), in, ne, gt, gte, lt, lte, regex (compiled at load). Keys in one when are ANDed; any = [ ... ] is a nestable OR. A missing attribute makes its comparison false; an attribute the capability never has is a validation error.

  5. 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, embed dimensions and encoding_format, rerank top_n, none for systemone). Unknown fields, fields outside the allow-list and wrong types are validation errors. Order: the requested virtual model’s preset first, then target overrides from the outermost virtual model inward, so the level closest to the foundation leaf wins.

  6. 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.

  7. SystemOne rerank remap. A remap on a target of a rerank virtual model, pointing directly at a foundation model that serves systemone, converts /v1/rerank into SystemOne calls. Strategies: noul (default, one noul per document), score (one score per document, normalised level / (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, else LM-1001 before any upstream call). A static operator-authored context is sent in the state next to the query. Batching and truncation follow the ADR 013 amendment. Accounting: usage.total_tokens is the sum of Jev’s reported input_tokens, cost uses the SystemOne model’s cost_per_1m_input. Preset and remap text is operator-authored config: never client-supplied, never logged, never echoed in errors.

  8. 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 fallbacks map); 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 walks Switch and Split nodes 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. The CancellationToken flows 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.

  9. Old fields removed, migration provided. fallbacks on a model, the [providers.models.rerank] block, and a typesafe model declaring rerank are validation errors that name the model, point to lumen config migrate and print the converted snippet. lumen config migrate rewrites old configs so clients keep working unchanged: a model M on provider P with fallbacks becomes foundation P/M plus a virtual M (fallback over P/M and the old fallbacks); a typesafe rerank model becomes a virtual rerank model with a noul remap onto the provider’s SystemOne foundation model. File mode backs up to <file>.bak and writes atomically; DB mode writes a new config_versions row; --dry-run prints the result.

Consequences

  • Observability. New x-lumen-route response header with the path taken (e.g. acme/chat>acme/chat-eu>mistral-large), a new usage_log.route column (migration 0010), a new Prometheus counter lumen_virtual_model_requests_total{virtual_model, model_used}, counted for served requests (virtual ids are operator-defined, so cardinality is bounded), and virtual_model and route tracing span fields (never prompts, presets or remap text). x-lumen-model-used and usage_log.model_used stay the foundation leaf that served, so tokens and cost are still booked to it (for a remap, the SystemOne model). GET /v1/models lists virtual models with "virtual": true and their single capability; foundation entries gain "virtual": false.
  • Request-path error codes. Unknown id LM-2001, wrong capability LM-2002, exhausted attempts LM-3004/LM-3020 or the last upstream error, invalid config LM-1001 on 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 is LM-2012 and a content-policy refusal is LM-2013, both invalid_request with the upstream 4xx status and the provider name, instead of the LM-3003 502 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 in docs/errors.md.
  • Admin API. /admin/config/virtual_models (list), and per-id GET, PUT, DELETE with the ADR 012 If-Match rule, plus a read-only /plan route that shows the fully resolved tree. Deleting a provider, foundation model or virtual model that a virtual model still references is LM-1001.
  • Breaking change, visible side effect. Renamed foundation ids change usage_log.model_used and Prometheus model labels (e.g. gpt-4o becomes openai/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 returns LM-2002 until the operator adds a second virtual model (it still works through P/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 (OpenRouter models/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

  1. Key push-sync. The Lab drives /admin over a private network with the master key (ADR 010). LUMEN never calls the Lab to authorize a request.
  2. 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.
  3. Two opaque refs: budget_groups.account_ref (the Lab account id) and virtual_keys.external_ref (the Lab key id). A key is billable when its group has an account_ref.
  4. 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 a billed_micro watermark. At every flush a billable key’s event is its settled cost since the watermark, so a delta is never negative. It is written to usage_outbox in 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} with group_id and PATCH /admin/groups/{id} with account_ref first 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.
  5. 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 in accepted. 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.)
  6. 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.)
  7. 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" and X-Lab-Signature, convert to credits, hold one secret per gateway, and run the lease top-up loop.
  • Per-request detail stays in usage_log and the export, best-effort.
  • While billing is on, a database or outbox outage blocks key moves between groups and account_ref changes (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_log path 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:

  1. 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] as url, instance_id, secret_env; the environment wins over the file, and signing_key_env stays as a deprecated alias with one boot warning). Every batch carries X-Lab-Instance-Id, X-Lab-Timestamp (unix seconds, fresh per attempt) and X-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.
  2. Self-description. One boot-time GET /internal/instances/me (bearer secret plus X-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 an account_ref. A 401 or 403, or a 2xx answer that is not this instance’s identity (malformed, another instance_id, another product, a kind other than hosted), 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, /health included) by at most usage_events.timeout_ms.
  3. Units, not a price (decision 6 amended). The event reports operation: "gateway", units {requests, tokens_in, tokens_out, tokens_estimated} and provider_cost_micro_usd (the settled-cost delta). The lease snapshot, source, key_id and window leave data, which the Lab-owned schema closes; the key id and window live in description (key <id> <start>..<end>). The schema is vendored byte for byte at contracts/vendor/lab/lab-events.schema.json, and the CI job lab-contract-drift fails on drift from meilisearch/lab when the LAB_REPO_TOKEN secret is set.
  4. One drop rule (decision 5 amended). The first 2xx answer that keeps an event out of accepted starts 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 and lumen_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.
  5. One gateway, many accounts (decision 1 extended). The Lab still drives /admin with the master key, but a hosted gateway is shared between accounts, so an /admin/* call carrying X-Lumen-Account-Ref is confined to that account’s groups and keys (lists filtered, foreign ids 404 LM-1003 like 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 new 403 LM-4005. Webhooks are platform-only rather than filtered because the receiver is one gateway-wide setting. GET /admin/groups/{id} exposes the live spent_micro and budget_max_micro the Lab’s lease sync reads; PATCH (budget_max) and grant set the lease.
  6. Opt-in from the environment too (decision 7 extended). The block is still the only switch and still boot layer, but LAB_URL and LAB_INSTANCE_ID in 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.
  7. 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

  1. developer is system wherever the upstream has no developer role. ChatMessage::is_system_role() (system or developer) is the only test a translator may use for instructions:
    • Anthropic and Bedrock hoist both into the top-level system, Gemini and Vertex into systemInstruction, in message order.
    • Cohere keeps instructions inline and sends developer as system.
    • OpenAI-compatible pass-through (Mistral, Azure, Ollama chat, Groq, Together, vLLM and every other compatible host) rewrites developer to system in place (developer_role_as_system).
    • Only OpenAI itself gets developer verbatim: the openai kind with its built-in URL or a base_url whose host is api.openai.com (OpenAiProvider::with_native_developer_role). kind = "openai" is also the documented way to reach unlisted compatible servers (LiteLLM, llama.cpp), so any other base_url gets the rewrite.
    • Virtual-model presets treat a client developer message as a client system prompt (replace removes it, if_absent sees it).
  2. 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_error logs one warn line, upstream returned an error, with provider, status and upstream_error. That field is built only from the vendor’s message string (error.message, message, error_description, string error, 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/pydantic detail lists carry an input field). 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 as sk-, AIza, AKIA, gsk_, a 20+ character segment mixing letters and digits, or the value an upstream prints after a credential word such as key, token or password), then cut to 512 characters. It sits under the request span, so it shares the request_id of the request failed line. The body is still never returned to a client.

Consequences

  • Meilisearch chat with source: openAi works against any LUMEN model; the vLlm source workaround is no longer needed.
  • A provider added later must use is_system_role(), not role == "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, as docs/operations/logging.md shows.
  • A client that keeps sending requests the upstream rejects now produces two lines per request (warn detail plus request failed).
  • Rewriting to system loses 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/systemone as the only shape)
  • Amends: ADR 014 (a rerank remap targets any decision model; strategy noul renamed predicate)

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
D1One capability, renamed systemone to decisions.
D2POST /v1/decisions is the public endpoint. It accepts both formats, detected from the body, and answers in the format it received.
D3POST /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).
D4The core type follows OpenAI’s schema (ordered questions, optional names, images, refusals), widened so text fields may hold structured JSON.
D5Five 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.
D6The rerank remap targets any decision model; noul strategy renamed predicate (alias kept).
D7Jev rerank prompts stay byte-identical to today (structured {document, question} instructions). Comparing structured vs plain-text prompts is a follow-up evaluation.
D8Image support reuses the existing per-model modalities ("image") and LM-2003.
D9Same-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 migrate rewrites it); the GET /v1/models value and the capability metric label change (breaking, CHANGELOG).
  • /v1/systemone was planned as deprecated in 0.6.0 and removed in 0.7.0, with Deprecation and Link headers and a lumen_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/systemone is removed outright in 0.7.0 (it answers 404 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/decisions instead.
  • 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 openai target is rendered as {"type": "refusal"}, which TypeSafe SDKs may not deserialize.

Alternatives rejected

  • One endpoint per vendor format (/v1/decisions OpenAI 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

  1. 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 fallback flag (default off). Keys are sealed at rest with the master key, never returned (only label, 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. DELETE evicts the key from memory synchronously; a reload then reconciles with the store. The request path reads pools from memory only.
  2. 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 for max(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 with LM-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 with LM-3021 without calling upstream, so a chain can never silently reach the platform’s keys.
  3. Team-served requests settle non-billable. No token cost is added to the settled or spent counters and the usage_log cost is 0. Their tokens are reported as byok_tokens_in / byok_tokens_out.
  4. Bandwidth metering. A /v1 middleware 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: UpstreamMark is set inside the closures that call upstream. Gateway refusals (auth, invalid request, budget, RPM/TPM, team keys exhausted) are not billed.
  5. Bandwidth charge. Bytes are charged to the key and group spent_micro at the group’s bandwidth_micro_usd_per_gb (integer, ceil, 1 GB = 10^9 bytes), never to the Lab billing counter settled_micro, so the ADR 015 settled-cost event is not double counted. A group with a budget_max refuses when spent + reserved >= budget_max even 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 as floor(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.
  6. Usage events. New units bytes_in, bytes_out, byok_tokens_in, byok_tokens_out. tokens_in/tokens_out and provider_cost_micro_usd cover platform-key traffic only. A flush window with only bandwidth or BYOK traffic still emits an event (provider cost 0). The vendored schema contracts/vendor/lab/lab-events.schema.json is 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 the lumen_circuit_state gauge, 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 answers LM-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}/grant may have landed; the docs tell billing automation to verify with a GET before retrying. A client-supplied Idempotency-Key header (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 disabled flag (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 same GroupEntry shape.
  • No lumen groups offline subcommand. Groups are created via POST /admin/groups (master-key gated, no virtual key needed), so there is no bootstrap chicken-and-egg; lumen keys create --group-id covers the offline key path. Add the subcommand only if a real workflow needs fully offline group provisioning.
  • No lumen_group_budget_remaining gauge. Pool spend is visible via GET /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. PATCH on a key or group budget is a read-modify-write for the caller; a POST .../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 is model, the handler could keep the client’s Bytes, 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-backed /v1/rerank. Done (ADR 014): a rerank virtual model whose target carries a remap (noul, score, composite, choice, static context), with per-tenant rules through switch on the budget group and a Cohere fallback. Design notes in docs/design/systemone-rerank-mapping.md.
  • GET /v1/models in 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 = true breaks release builds of proc-macro dylibs on macOS 27 (mis-aligned LINKEDIT string pool loading sqlx-macros), so cargo bench fails locally unless CARGO_PROFILE_RELEASE_STRIP=false. Consider strip = "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 perplexity kind (chat is already served through its OpenAI-compatible endpoint).
  • decisions on the existing openrouter kind (/api/alpha/decisions): one key for about 15 models, including Respan and Tev1; needs noul criteria synthesis and provider routing preferences.
  • Respan’s native /scores API 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 SystemOne choice call to Jev whose options are the targets’ descriptions; the most probable option wins, below min_confidence the default target is used) and semantic (target descriptions embedded at load, one embedding call per request, a dot product per target, below min_similarity the 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. preset is chat-only. Embed (default dimensions) and rerank (default top_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_versions rows (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. A GET /admin/config/history (id, hash, applied_at) and a POST /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/import CLI. File<->DB mode migration today is a manual GET/merge/restart procedure (see Config source modes). An offline lumen config export --config <path> (dump the boot file’s dynamic keys, or a DB-mode document, as portable TOML) and lumen 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 entire models array along with it. A PUT/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 postgres sqlx feature flag; the queries in crates/auth are 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.rs is 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 for /v1/embeddings (input as arrays of token ids) are not modelled - only string and string-batch. Resolved (issue #25). EmbedInput now models Tokens ([1,2,3]) and TokenBatch ([[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.
  • Rerank documents accepts only strings; Cohere also allows objects. Resolved (issue #25). See the M3 note below.
  • error_type() collapses 401/402/429 into invalid_request because the public taxonomy only has three types. Fine per CLAUDE.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 (shutdown_signal) has no integration test (hard to do portably). Acceptable; revisit if signal handling grows. Resolved (issue #27). crates/server/tests/signal_shutdown.rs (#[cfg(unix)]) spawns the real lumen binary and sends it a genuine SIGTERM/SIGINT via libc::kill, asserting the same drain-then-exit-0 behaviour the injected- oneshot tests already prove for serve() itself - now proven for the actual tokio::signal path too.

Noted while building M2

  • Embedding output is always a float array in v1. Base64 embeddings are decoded on the way IN (a client requesting encoding_format: "base64" won’t error), but we do not re-encode on the way OUT. Resolved (issue #25). When 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 upstream encoding_format).
  • Ollama drops the OpenAI-only dimensions field with a debug! log; a client asking for a specific dimension silently gets full-width vectors. Resolved (issue #25). A per-provider strict = true makes Ollama reject a request that sets dimensions with a 400 (LM-1001) instead of silently dropping it; the default stays lenient. encoding_format is no longer lost either (handled at the edge, above).
  • LM-1002 (payload too large, 413) is emitted by RequestBodyLimitLayer as a raw 413 without our JSON error envelope. Map the tower-http rejection to GatewayError::PayloadTooLarge for 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 (conformance scenario_cancellation_aborts_upstream). Update (issue #27): this predicted flake happened - mistral_passes_embed_conformance_suite flaked once under full workspace-test parallelism. Widened the mocked upstream delay (2s → 3s) and the elapsed-time assertion (1s → 2s) in scenario_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 real reqwest/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 sends search_document unless overridden. Resolved (issue #22): a caller may set input_type as an extra field on the /v1/embeddings request body (search_document, search_query, classification, or clustering); an unknown value is rejected with LM-1001 before any upstream call. See docs/providers.md § cohere. A per-model default (config-side) is still open if per-request opt-in proves insufficient in practice.
  • usage.search_units is only meaningful for Cohere; Jina and Voyage bill rerank in tokens. Resolved (issue #10): RerankUsage now carries a separate total_tokens/tokens_estimated pair, upstream-reported for Jina/Voyage and gateway-derived (from query + documents) for every other provider.
  • Rerank documents accept string or {text} only. Cohere also allows arbitrary objects with a rank_fields selector - out of scope. Resolved (issue #25). RerankDocument::Object now keeps all fields; the request carries an optional rank_fields selector, and the gateway reduces each object document to a single ranking text at the edge (selected fields joined, or the text field when no selector), so providers still only ever see plain text. With return_documents: true, an object document echoes that reduced ranking text in document.text, not the original JSON object.
  • TEI serves one model per process and ignores the request model/top_n; the gateway truncates to top_n after sorting. The configured upstream_id is 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_size conservatively (Cohere 96, Jina/Voyage/OpenAI-style large, TEI 32). Revisit against real provider limits; embeddings batching already exercises these.
  • Per-model embedding max_batch_size override (surfaced by issue #90). Vertex embeddings hardcode max_batch_size() = 1 because gemini-embedding-001 accepts a single instance per :predict call, but the other text-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 current EmbeddingProvider::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_server proves 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-shot chat_stream awaits the full chat() 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_request copies message roles verbatim and does not normalise user/assistant alternation or drop/merge tool/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; set SystemTime::now() if one complains.
  • to_sse_body’s Chain drops 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 (commit 076b909, slice 3). When the upstream closes without a [DONE] terminator and without a transport error, the gateway now appends a terminal data: {"error": {"code": "LM-3010"...}} frame then closes cleanly. The lightweight tail-watcher (EventStreamState::scan_frame in crates/server/src/chat.rs) inspects only frame boundaries - it matches a line-anchored \ndata: [DONE] marker and keeps at most DONE_MARKER.len() - 1 trailing 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 test upstream_stream_without_done_yields_fg3010_error_frame (passthrough), plus the mid-stream error-frame tests (mid_stream_provider_error_becomes_terminal_error_frame and resilience.rs).

  • Tools on Gemini: RESOLVED (issue #4). translate_request (google) now maps OpenAI tools to Gemini tools[].functionDeclarations and tool_choice to toolConfig.functionCallingConfig; assistant tool_calls become functionCall parts, role tool messages become functionResponse parts, and both the non-streaming and streaming translators surface Gemini functionCall parts as OpenAI tool_calls. Chose the full mapping over the LM-2002 rejection. Covered by unit tests and wiremock round-trips in crates/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": exact tiktoken-rs BPE (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_usage is auto-requested and translators always emit usage), the output-token estimate is the number of data: 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, status carries 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. PUT /admin/provider-keys takes effect at the next restart; wire it into the M7 hot-reload path. 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.
  • 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 SQLite json_extract works. 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 - large max_tokens reservations 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. MasterKey and the raw env string are not zeroized on drop; the zeroize crate 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 set connect_timeout_ms; it then gets its own (unpooled) reqwest::Client built 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 by first_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_url for 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 as POST /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).

  • health_stays_fast_under_upstream_429_storm flaky under a 500-connection storm. Resolved (issue #27; the storm path’s kernel-level connect retry landed via PR #42). Several dev sandboxes hit a client-side panic (reqwest::Error from a TCP connect reset or a broken-pipe SendRequest) when firing 500 concurrent requests at once - a saturated OS accept backlog, not gateway behaviour. crates/server/tests/resilience.rs’s post_chat helper now retries is_connect()/is_request() transport errors (never a received HTTP response) with a short backoff before giving up, and the storm size is overridable via LUMEN_RESILIENCE_STORM_SIZE (defaults to the unchanged CI-scale 500) as a secondary escape hatch. The status-code assertions (429/503 only, /health latency 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 the translate_request/translate_response/stream translators directly needs a small public (or #[cfg(fuzzing)]) shim over the currently-private functions. Resolved (issue #27). Each of providers::anthropic/providers::google now has a #[cfg(fuzzing)] pub mod fuzzing shim (compiled only under cargo fuzz, which sets --cfg fuzzing across the dependency graph - zero normal-build surface change) exposing translate_request/translate_response. Four new targets (anthropic_translate_request, anthropic_translate_response, google_translate_request, google_translate_response) in fuzz/, wired into the weekly fuzz CI matrix; see fuzz/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 reproducible bench/ harness (docker-compose + k6) run by the operator, not captured as a committed baseline. Resolved (issue #27). bench/run.sh drives the full harness end to end (pinned, digest-locked images; one command) and writes a timestamped, committed result under bench/results/. A recorded baseline is linked from docs/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-run bench/run.sh on 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 a stream: true response, 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 - the xk6-sse extension (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 in cargo 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, and PUT /admin/provider-keys triggers 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. MasterKey wipes on drop and the raw LUMEN_MASTER_KEY string 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 (85 tokens for "detail": "low", 765 for "high"/"auto"/unset) instead of counting an image part as 0. A true per-dimension tile count (OpenAI’s 85 + 170 * tiles) still needs decoded pixel dimensions, which the gateway does not extract from a data: 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), remote http(s) URLs are supported. Anthropic’s source: {type: "file", file_id: ...} and Gemini’s GCS fileUri sources 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: dedicated api_version config field - a desired fast-follow to the shipped azure kind, which currently reads the version from an ?api-version=... query string on base_url. A first-class field needs a matching ProviderSpec + crates/server/src/config.rs change. Resolved (issue #65). The provider config now takes an optional api_version field (azure-only, rejected on other kinds at boot), threaded through ProviderSpec into AzureProvider. Precedence: the explicit field wins over an ?api-version=... query string on base_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 with image_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 under crates/providers/src/; the section’s “cheap differentiation for a first-class rerank gateway” goal is done.

Noted while building M8 (vision / image input)

  • LM-2004 pre-flight is primary-only. A remote http(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 as LM-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-input ProviderError mapped 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 and LM-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 ToolMessageV2 cannot carry images), so an image part on a tool message 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, including ollama (its /v1 chat endpoint takes base64 data: URIs only). So a remote http(s) image URL on a vision-declared ollama model skips the LM-2004 pre-flight and fails opaquely upstream instead of as an honest client 4xx. Candidate fix: gate accepts_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:

➡️ CONTRIBUTING.md on GitHub

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.