Backlog
Ideas surfaced during development that are intentionally out of scope for v1
(see CLAUDE.md → “What we do NOT do (v1)” and ROADMAP.md → “Backlog v2”).
Recorded here so they are not lost, and so we don’t gold-plate the current
milestone.
Noted while building ADR 009 (shared parent budgets)
- Grant routes are not idempotent (ADR 009 amendment). A timed-out
POST /admin/{keys,groups}/{id}/grantmay have landed; the docs tell billing automation to verify with a GET before retrying. A client-suppliedIdempotency-Keyheader (dedup table keyed on it, replay the recorded response) would make retries safe; add it when a real billing integration asks. - Groups carry budget only. Group-level RPM/TPM, a group
disabledflag (pause a whole customer), group expiry, and nested groups are all deliberate non-goals of the first slice; each is a natural follow-up on the sameGroupEntryshape. - No
lumen groupsoffline subcommand. Groups are created viaPOST /admin/groups(master-key gated, no virtual key needed), so there is no bootstrap chicken-and-egg;lumen keys create --group-idcovers the offline key path. Add the subcommand only if a real workflow needs fully offline group provisioning. - No
lumen_group_budget_remaininggauge. Pool spend is visible viaGET /admin/keys-style reads (GET /admin/groups) and the usage log; a Prometheus gauge per group would be cheap but adds a per-group metric series - decide when a dashboard actually wants it. - Atomic budget top-up.
PATCHon a key or group budget is a read-modify-write for the caller; aPOST .../grant {"amount"}increment route would remove the need to serialize concurrent top-ups in the control plane.
Deferred to v2 (from the vision)
- Web admin UI
- Semantic cache
- Audio (input/output) support. Image input already shipped in v1: chat vision (M8) and multimodal embeddings (M9).
- Guardrails / moderation
- Distributed rate limiting (Redis)
- OTLP tracing export
- WASM plugin system
- Postgres backend for the auth/usage store (a
postgressqlx feature flag; the queries incrates/authare simple enough to stay portable, so this is cheap to add if a deployment ever needs it - v1 is SQLite only) /v1/batches(OpenAI async batch jobs API) - explicit v1 non-goal: batch jobs imply persistent job state and scheduling, which sits poorly with the DB-off-the-request-path and single-binary pillars. Note:crates/providers/src/batch.rsis embedding request sub-batching, unrelated to this API surface./v1/files(OpenAI file upload/storage API) - explicit v1 non-goal: blob storage state, same rationale as/v1/batches.
Noted while building M1
Token-array inputs forResolved (issue #25)./v1/embeddings(inputas arrays of token ids) are not modelled - only string and string-batch.EmbedInputnow modelsTokens([1,2,3]) andTokenBatch([[1,2],[3,4]]); they pass through natively on OpenAI-compatible providers and count one token per id in the estimation fallback. Text-only providers (Cohere, TEI, Ollama, Jina, Voyage, Mistral) reject them with a 400 (LM-1001) before any upstream call.RerankResolved (issue #25). See the M3 note below.documentsaccepts only strings; Cohere also allows objects.error_type()collapses 401/402/429 intoinvalid_requestbecause the public taxonomy only has threetypes. Fine perCLAUDE.md, but note it’s coarse.- Acceptance criterion “boot < 100 ms” is verified manually (M1); fold a real timing assertion into the M7 criterion benchmarks rather than a flaky unit test.
Graceful shutdown is unit-tested via an injected shutdown future; the real SIGINT/SIGTERM path (Resolved (issue #27).shutdown_signal) has no integration test (hard to do portably). Acceptable; revisit if signal handling grows.crates/server/tests/signal_shutdown.rs(#[cfg(unix)]) spawns the reallumenbinary and sends it a genuineSIGTERM/SIGINTvialibc::kill, asserting the same drain-then-exit-0 behaviour the injected- oneshot tests already prove forserve()itself - now proven for the actualtokio::signalpath too.
Noted while building M2
Embedding output is always a float array in v1. Base64 embeddings are decoded on the way IN (a client requestingResolved (issue #25). Whenencoding_format: "base64"won’t error), but we do not re-encode on the way OUT.encoding_format: "base64"is requested, the gateway re-encodes each vector as OpenAI-style base64 at the response edge, so it works for every provider (including Ollama and TEI, which have no upstreamencoding_format).Ollama drops the OpenAI-onlyResolved (issue #25). A per-providerdimensionsfield with adebug!log; a client asking for a specific dimension silently gets full-width vectors.strict = truemakes Ollama reject a request that setsdimensionswith a 400 (LM-1001) instead of silently dropping it; the default stays lenient.encoding_formatis no longer lost either (handled at the edge, above).LM-1002(payload too large, 413) is emitted byRequestBodyLimitLayeras a raw 413 without our JSON error envelope. Map the tower-http rejection toGatewayError::PayloadTooLargefor a consistent body.- Cancellation tests use real (short) wall-clock delays rather than
tokio(start_paused); robust today but revisit if they flake under CI load. The HTTP-level disconnect test asserts the server stays responsive and the upstream got the request - the actual upstream abort is proven at the provider layer (conformancescenario_cancellation_aborts_upstream). Update (issue #27): this predicted flake happened -mistral_passes_embed_conformance_suiteflaked once under full workspace-test parallelism. Widened the mocked upstream delay (2s → 3s) and the elapsed-time assertion (1s → 2s) inscenario_cancellation_aborts_upstream(crates/providers/tests/embeddings.rs) for more scheduler-jitter headroom without weakening what the assertion proves (still asserts the call returns in well under half the mocked delay).tokio(start_paused)would sidestep wall-clock entirely but doesn’t compose with the realreqwest/wiremock I/O this suite exercises; deferred unless the wider margin still flakes.
Noted while building M3
- Cohere v2 embed requires an
input_type; the gateway can’t know query-vs- document intent by default, so it sendssearch_documentunless overridden. Resolved (issue #22): a caller may setinput_typeas an extra field on the/v1/embeddingsrequest body (search_document,search_query,classification, orclustering); an unknown value is rejected withLM-1001before any upstream call. Seedocs/providers.md§ cohere. A per-model default (config-side) is still open if per-request opt-in proves insufficient in practice. usage.search_unitsis only meaningful for Cohere; Jina and Voyage bill rerank in tokens. Resolved (issue #10):RerankUsagenow carries a separatetotal_tokens/tokens_estimatedpair, upstream-reported for Jina/Voyage and gateway-derived (fromquery + documents) for every other provider.RerankResolved (issue #25).documentsaccept string or{text}only. Cohere also allows arbitrary objects with arank_fieldsselector - out of scope.RerankDocument::Objectnow keeps all fields; the request carries an optionalrank_fieldsselector, and the gateway reduces each object document to a single ranking text at the edge (selected fields joined, or thetextfield when no selector), so providers still only ever see plain text. Withreturn_documents: true, an object document echoes that reduced ranking text indocument.text, not the original JSON object.- TEI serves one model per process and ignores the request
model/top_n; the gateway truncates totop_nafter sorting. The configuredupstream_idis informational for TEI. A future health/introspection hook could verify the configured model matches what the TEI process actually serves. - The four hosted rerank providers default
max_batch_sizeconservatively (Cohere 96, Jina/Voyage/OpenAI-style large, TEI 32). Revisit against real provider limits; embeddings batching already exercises these. - Per-model embedding
max_batch_sizeoverride (surfaced by issue #90). Vertex embeddings hardcodemax_batch_size() = 1becausegemini-embedding-001accepts a single instance per:predictcall, but the othertext-embedding-*models take up to 250. A single conservative value means a 1000-input request is ~250 sequential rounds at concurrency 4. A config override (or a larger known-safe default for the text-embedding models, keyed by model) would soften this bottleneck. Deferred: it needs per-model config plumbing that the currentEmbeddingProvider::max_batch_size(provider-wide, model-agnostic) does not carry.
Noted while building M4 (slice 1 - non-streaming chat)
- Streaming disconnect test is a no-hang assertion, not an abort assertion.
streaming_client_disconnect_does_not_hang_serverproves the server stays responsive but not that the upstream connection was actually closed (M4 acceptance criterion 2: “upstream closed in < 100 ms”). And because the interim single-shotchat_streamawaits the fullchat()before the guard is moved into the SSE body, the moved-guard path is not exercised. Strengthen in the streaming slice: assert via wiremock that the upstream request was aborted, and add a case where the client cuts after the body starts. - Anthropic
translate_requestcopies message roles verbatim and does not normalise user/assistant alternation or drop/mergetool/empty-content messages (spec 4.3 bullet). Fine for the interim text path; complete with tool translation in the streaming slice. - Anthropic responses set
created: 0(the API returns no timestamp). Some OpenAI clients expect a real epoch; setSystemTime::now()if one complains. to_sse_body’sChaindrops the mapping closure (and thus the cancel guard) as soon as the chunk stream is exhausted, a hair before the[DONE]frame. Benign today (nothing left to cancel once the upstream is done), but the real streaming slice must not tie a live resource to guard survival through[DONE].- Interim single-shot emits
[DONE]even after a mid-stream error frame. Harmless with one item; real streaming must terminate after an error.
Noted while building M4 (slice 2 - zero-copy streaming)
-
Acceptance criterion 5 (LM-3010) not yet implemented.Resolved (commit076b909, slice 3). When the upstream closes without a[DONE]terminator and without a transport error, the gateway now appends a terminaldata: {"error": {"code": "LM-3010"...}}frame then closes cleanly. The lightweight tail-watcher (EventStreamState::scan_frameincrates/server/src/chat.rs) inspects only frame boundaries - it matches a line-anchored\ndata: [DONE]marker and keeps at mostDONE_MARKER.len() - 1trailing bytes, so a terminator split across two frames is still detected and model content that merely contains the text can never spoof it. Covered by the unit guard tests in that module and the end-to-end wiremock testupstream_stream_without_done_yields_fg3010_error_frame(passthrough), plus the mid-stream error-frame tests (mid_stream_provider_error_becomes_terminal_error_frameandresilience.rs). -
Tools on Gemini: RESOLVED (issue #4).
translate_request(google) now maps OpenAItoolsto Geminitools[].functionDeclarationsandtool_choicetotoolConfig.functionCallingConfig; assistanttool_callsbecomefunctionCallparts, roletoolmessages becomefunctionResponseparts, and both the non-streaming and streaming translators surface GeminifunctionCallparts as OpenAItool_calls. Chose the full mapping over the LM-2002 rejection. Covered by unit tests and wiremock round-trips incrates/server/tests/chat.rs.
Noted while building M5
- Accurate per-model tokenizer - DONE (issue #8). ADR 003’s opt-in
“accurate tokenizer via
spawn_blocking” now ships behind[tokenizer] mode = "accurate": exacttiktoken-rsBPE (cl100k_base / o200k_base by model prefix) refines the local fallback AFTER the response is handed off (deferred accounting close, BPE on the blocking pool), landing in Prometheus and usage_log; the response envelope stays heuristic and the request is never delayed. Heuristic fallback for non-OpenAI models and any failure. The default stays the byte heuristic (zero cost). See the ADR 003 “opt-in accurate tokenizer” addendum. Remaining follow-up: streaming input counts stay heuristic (no buffered prompt to BPE under the ADR 004 passthrough rule). - Streaming output estimation = data-frame count. When a stream carries no
usage (rare:
include_usageis auto-requested and translators always emit usage), the output-token estimate is the number ofdata:frames (~1 token per delta for OpenAI-style streams). Crude but honest (estimated=true). - usage_log records successful requests only.
Refusals (401/402/429) and upstream failures are visible in logs/metrics but produce no usage row.RESOLVED (issue #26, ADR 007): admission refusals (402/429) now enqueue a status-only usage row (zero tokens,statuscarries the rejection) through the same non-blocking channel. 401 stays unlogged (refused in the auth middleware, before accounting opens - no key to attribute); upstream failures are already logged by the normal finish path. - Rejected requests still count toward RPM/TPM.
Quota bumps are not unwound when a later admission step (budget) refuses the request.RESOLVED (issue #26, ADR 007): a request refused inside admission (TPM after RPM, or the budget after both) now rolls back the bumps it already made, so it consumes no quota. (A request refused at its own step never bumped that window.) - DB-stored provider keys are boot-time only.
Done (post-v0.1.0): the admin route pings the hot-reload trigger and every reload re-reads provider keys from the encrypted store, so a rotation applies without a restart. Env-sourced keys keep precedence.PUT /admin/provider-keystakes effect at the next restart; wire it into the M7 hot-reload path. - Metadata values are stringified in
usage_log.metadata.JSON object of strings.RESOLVED (issue #26, ADR 007): the column now stores typed JSON ({"batch":42,"canary":true}), so numeric/boolean filtering via SQLitejson_extractworks. Prometheus labels still stringify (labels are strings). - TPM debits the pre-call estimate, never adjusted.
Unlike the budget (reserved then settled to real usage), the tokens-per-minute window keeps the estimate.RESOLVED (issue #26, ADR 007): a successful request now settles the TPM window to the real token count, mirroring the budget - largemax_tokensreservations no longer starve a key. A dropped (failed/cancelled) reservation deliberately keeps the TPM debit: a request that hit the gateway still counts against the rate limit. - No zeroization of key material.
MasterKeyand the raw env string are not zeroized on drop; thezeroizecrate would close the residual-memory window. Low risk (single long-lived process), noted from the M5 review.
M6 (resilience) - deferred
-
Per-provider connect timeout.Done (issue #24, 2026-07-15.) A provider may setconnect_timeout_ms; it then gets its own (unpooled)reqwest::Clientbuilt at registry construction, while every non-overriding provider keeps sharing the one pooled client. See the ADR 005 amendment. -
First-frame-peek streaming retry.DONE (issue #7, 2026-07-15). The commitment point moved from the open phase to the first content frame: the streaming path now peeks the first upstream frame, so a stream that opens 200 then errors or closes before any content frame fails over (and penalises the breaker) instead of committing to a terminal SSE error. Buffers at most one frame, bounded byfirst_token, cancellation-safe. See ADR 005, 2026-07-15 amendment;crates/router/src/peek.rs. -
Circuit-breaker map is unbounded by design. One entry per (provider, model) actually seen; bounded by the configured surface, never by client input. If a future dynamic-model feature lets clients mint arbitrary model ids, add an LRU cap.
-
Health probe is a bare GET to
base_urlfor keyed vendor kinds. TEI, vLLM and Ollama now get a real, unauthenticated liveness endpoint (DEBT-3, issue #23). Keyed vendor kinds (OpenAI, Anthropic, the OpenAI-compatible hosts, …) still get bare reachability only: their liveness routes (GET /v1/models, etc.) require an API key the probe task does not carry, and an unauthenticated call there would 401 a healthy server - a worse signal than bare reachability. Plumbing provider credentials into the probe task to unlock this is deferred to keep the probe free and side-effect-free. -
Resolved (issue #27; the storm path’s kernel-level connect retry landed via PR #42). Several dev sandboxes hit a client-side panic (health_stays_fast_under_upstream_429_stormflaky under a 500-connection storm.reqwest::Errorfrom a TCP connect reset or a broken-pipeSendRequest) when firing 500 concurrent requests at once - a saturated OS accept backlog, not gateway behaviour.crates/server/tests/resilience.rs’spost_chathelper now retriesis_connect()/is_request()transport errors (never a received HTTP response) with a short backoff before giving up, and the storm size is overridable viaLUMEN_RESILIENCE_STORM_SIZE(defaults to the unchanged CI-scale 500) as a secondary escape hatch. The status-code assertions (429/503 only,/healthlatency bound) are unchanged - the fix is purely about not letting host-level connection churn panic the test.
M7 (release) - deferred
- Hot reload swaps routing, pricing, resilience and the safe auth knobs.
SIGHUP / file-watch / admin-trigger re-validate the config and atomically swap
the provider registry (ArcSwap), the price table, the resilience policy and the
runtime-safe
[auth]knobs (flush_interval_ms,retention_days). Still restart-only, by design: the server bind address (rebinding a live listener is high-risk),auth.enabled,auth.db_path, and the bounded usage-log channel knobs (usage_channel_capacity,usage_batch_max,usage_flush_ms) whose capacity is fixed when the channel is created. - Hot reload re-resolves env keys fresh; DB keys are re-read each reload. A
reload re-reads provider keys from the environment and, for env-keyless
providers, from the encrypted DB store, so rotating a DB-stored key
(
PUT /admin/provider-keys) after boot takes effect on the next reload (the admin route triggers one) with no restart. A DB read error keeps the previous snapshot so a reload never strips a stored key. Anthropic/Gemini translation fuzzing goes only as deep as the shared SSE parser today. Fuzzing theResolved (issue #27). Each oftranslate_request/translate_response/stream translators directly needs a small public (or#[cfg(fuzzing)]) shim over the currently-private functions.providers::anthropic/providers::googlenow has a#[cfg(fuzzing)] pub mod fuzzingshim (compiled only undercargo fuzz, which sets--cfg fuzzingacross the dependency graph - zero normal-build surface change) exposingtranslate_request/translate_response. Four new targets (anthropic_translate_request,anthropic_translate_response,google_translate_request,google_translate_response) infuzz/, wired into the weekly fuzz CI matrix; seefuzz/README.md“Why#[cfg(fuzzing)]shims” for the alternatives considered.Loaded throughput vs LiteLLM not measured in-repo. The in-process overhead (~3 µs) is benchmarked; the full p50/p99/RAM/req·s head-to-head is a reproducibleResolved (issue #27).bench/harness (docker-compose + k6) run by the operator, not captured as a committed baseline.bench/run.shdrives the full harness end to end (pinned, digest-locked images; one command) and writes a timestamped, committed result underbench/results/. A recorded baseline is linked fromdocs/perf-baseline.md- read that section’s caveat before trusting the absolute numbers: it was recorded on a shared dev host, not dedicated hardware, so the relative LUMEN-vs-LiteLLM comparison is solid but the absolute figures are illustrative. Re-runbench/run.shon real hardware for numbers to make capacity decisions on.- Streaming time-to-first-token in the k6 harness. The head-to-head now
reports non-streaming time to first byte (k6’s built-in
http_req_waiting), but a streaming TTFT column (first SSE chunk of astream: trueresponse, per target, under load) is not measurable with stock k6: it buffers response bodies, so the first body chunk cannot be timestamped. Doing it in the harness needs a body-aware load client - thexk6-sseextension (custom k6 build via xk6) or a small purpose-built Rust/Go client - plus a mock upstream that actually paces its SSE frames (mockserver writes the whole body at once). Until then the streaming first-bit number lives off-harness incargo bench -p server --bench stream_ttfb(single-request latency, direct vs via-gateway, no concurrent load).
Backlog debt paid down (post-v0.1.0)
- Full-config hot reload (DEBT-1) - done. Reload now swaps pricing and the resilience policy (retry/timeouts/fallbacks) as well as the routing table, preserving circuit-breaker state.
- Auth-knob hot reload + DB provider-key rotation - done. Reload also swaps
the safe
[auth]knobs (flush_interval_ms,retention_days) and re-reads DB-stored provider keys, andPUT /admin/provider-keystriggers a reload so a rotation applies without a restart. Server bind address stays boot-time (see the M7 note above for the exact restart-only surface). - Key-material zeroization (DEBT-2) - done.
MasterKeywipes on drop and the rawLUMEN_MASTER_KEYstring is zeroized after use. - Richer health probe (DEBT-3, issue #23) - done for the self-hosted,
keyless kinds: TEI (
/health), vLLM (/health) and Ollama (/api/version), each a real liveness endpoint (non-2xx = down). Keyed vendor kinds keep bare host-reachability (no reliable unauthenticated liveness endpoint); a per-kind, authenticated probe for vendor APIs remains out of scope (see the M6 entry above).
Noted while building M8 (vision - image input to chat)
- Per-image token heuristic for the estimation fallback - done (issue #9).
The estimation fallback (upstream reports no
usage) now adds a flat per-image constant (85tokens for"detail": "low",765for"high"/"auto"/unset) instead of counting an image part as0. A true per-dimension tile count (OpenAI’s85 + 170 * tiles) still needs decoded pixel dimensions, which the gateway does not extract from adata:URI today and remains out of scope (no image-byte inspection on the request path). See the ADR 003 addendum. - Anthropic/Gemini file/GCS image URIs. Only inline base64 (
data:URIs) and, where the provider fetches it itself (Anthropic), remotehttp(s)URLs are supported. Anthropic’ssource: {type: "file", file_id: ...}and Gemini’s GCSfileUrisources are not modelled; add if a caller needs pre-uploaded-file references instead of inline bytes.
Provider coverage - next candidates (post-rename)
- Tier-2 clouds need dedicated kinds (different auth/schema, not
OpenAI-compatible):
Azure OpenAI (deployment routing + api-version)(shipped -kind = "azure"),AWS Bedrock (SigV4, per-model schemas)(shipped -kind = "bedrock", Converse API + SigV4,crates/providers/src/bedrock/),Google Vertex AI (GCP OAuth, regional endpoints)(shipped -kind = "vertex_ai",crates/providers/src/google/vertex/). Azure: dedicatedResolved (issue #65). The provider config now takes an optionalapi_versionconfig field - a desired fast-follow to the shippedazurekind, which currently reads the version from an?api-version=...query string onbase_url. A first-class field needs a matchingProviderSpec+crates/server/src/config.rschange.api_versionfield (azure-only, rejected on other kinds at boot), threaded throughProviderSpecintoAzureProvider. Precedence: the explicit field wins over an?api-version=...query string onbase_url(kept for back-compat), which wins over the pinned built-in default.Cohere chat (Command R/R+) - we ship Cohere embed+rerank; chat is a distinct schema.Resolved (shipped -crates/providers/src/cohere/chat.rs, text chat + streaming + tools). The remaining slice is vision/image input only: the chat translation maps content withimage_url: None, tracked as issue #73.- More rerankers:
Mixedbread (mxbai-rerank)(shipped -kind = "mixedbread"),Pinecone Rerank(shipped -kind = "pinecone"),NVIDIA NIM rerank(shipped -kind = "nvidia"),Together LlamaRank(shipped -kind = "together"). All four live undercrates/providers/src/; the section’s “cheap differentiation for a first-class rerank gateway” goal is done.
Noted while building M8 (vision / image input)
LM-2004pre-flight is primary-only. A remotehttp(s)image URL is rejected up front (LM-2004, 400) only when the model’s primary provider can’t fetch it (Gemini). If the primary accepts URLs (OpenAI) but a Gemini model is a fallback, a fail-over to Gemini surfaces asLM-3002(502, translation error) rather than a client 4xx - safe (never fetched, no retry loop) but a soft break of the 4xx/5xx separation (rule 8). Options if it ever bites: scan the whole chain in the pre-flight (rejects some primary-servable requests), or add a dedicated client-inputProviderErrormapped to a 4xx.- Per-image token heuristic for the estimation fallback - done (issue #9, see the note above under “M8 (vision - image input to chat)”). The remaining gap is a true dimension-based tile count, which needs image decoding this gateway deliberately does not do on the request path.
- Provider-native image URI forms. Anthropic/Gemini file & GCS URI image sources (beyond inline base64 + remote URL) are not modelled.
- Tool-role messages with image parts are silently flattened (noted while
fixing issue #73). The Cohere translator gates its v2 image blocks on the
user role (Cohere’s
ToolMessageV2cannot carry images), so an image part on atoolmessage is flattened to its text - consistent with the Anthropic translator today, but honest handling would be a 400 before the upstream call. Candidate: a shared role-aware content check in the M8 pre-flight instead of per-translator conventions. - Remote image URLs on OpenAI-compatible-path providers whose upstream only
accepts base64 (noted while wiring ollama chat, issue #63). Every kind
served by the shared OpenAI provider inherits
accepts_remote_image_url() = true, includingollama(its/v1chat endpoint takes base64data:URIs only). So a remotehttp(s)image URL on a vision-declared ollama model skips theLM-2004pre-flight and fails opaquely upstream instead of as an honest client 4xx. Candidate fix: gateaccepts_remote_image_url()on the provider kind (or a per-spec flag) rather than on the implementing provider type.