ADR 014 - Virtual models
- Status: accepted
- Date: 2026-09-30
- Supersedes: the open proposals in
docs/design/systemone-rerank-mapping.md - Builds on: ADR 003, ADR 005, ADR 009, ADR 012, ADR 013
Context
Today a [[providers.models]] entry does four jobs at once: it is the
upstream binding (provider, upstream_id), the public id clients send, the
pricing record, and the home of routing logic (fallbacks, and the Jev
[providers.models.rerank] converter from the ADR 013 amendment). Mixing the
jobs makes each new behaviour (weighted split, conditional routing, request
templates, Jev answering /v1/rerank with per-tenant relevance rules) a new
field on a model that is supposed to be a plain binding, and it makes the
logic impossible to compose: a fallback chain cannot point at another chain,
and a Jev reranker cannot fall back to a classic reranker.
The goals are:
- One place for routing logic. Foundation models become pure upstream bindings.
- Zero measurable cost for requests that use no transform: the routing decision stays well under 1 us.
- Jev as a first-class reranker, including per-tenant relevance rules (via
switchon the budget group) and a cross-capability fallback to a classic reranker. - Every served request still reports tokens and cost against the foundation model that actually served it (ADR 003).
Decision
-
Two layers. Foundation models (
[[providers.models]]) are pure upstream bindings:id,upstream_id,capabilities,modalities, prices,release_date. Virtual models ([[virtual_models]]) carry the logic. They live in the dynamic config document (ADR 012): config file orconfig_versionsrow, hot-reloaded, editable through/admin/config. Both layers are callable by clients. They share one id namespace (allowed characters[A-Za-z0-9._:/-], non-empty); a collision is a validation error naming both definitions. Circuit breakers stay keyed by (provider, model), as in ADR 005. -
One capability and one strategy per virtual model. A virtual model serves exactly one of
chat,embed,rerank,systemone, which keeps remaps and validation unambiguous and makesGET /v1/modelstruthful. The strategy is one of:single: the one target.fallback: targets in order; move on when the failure matchesfallback_on.split: pick one target at random by integerweight; on a matching failure try the rest in descending weight order (ties keep declaration order). No stickiness.switch: the first target whosewhenmatches is used. A chosen branch never falls through to the next rule; its resilience comes from what it points at.
Targets are foundation ids or other virtual ids (composition by reference). Load-time validation: references exist; strategy shape (
singlehas 1 target,fallbackandsplitat least 2,switchhaswhenon every target but the last and none on the last,whenonly underswitch,weightonly undersplit,fallback_ononly onfallback/split,presetonly onchat); capability compatibility (or aremap); no cycles (the error prints the path, e.g.a -> b -> a); nesting depth at most 8; at most 64 flattened attempts per request (MAX_ATTEMPTS: afallbackorsplitsums its targets, aswitchtakes its largest branch, a foundation target is 1), so a DAG listing the same child several times per level cannot blow up the decide step; contents ofoverrides,presetandremap. Validation runs at boot, reload and on every admin write.listed = falsehides a virtual model fromGET /v1/modelswhile it stays callable. -
Typed fallback triggers (
fallback_on). Names:provider_error(retryable upstream 5xx including TypeSafe 529 Overloaded, an unreachable upstream, premature stream end before the first byte; a malformed response and non-retryable upstream statuses never trigger a fallback),rate_limited(upstream 429),timeout(connect, first-token and per-attempt timeouts),circuit_open, plus the opt-incontext_lengthandcontent_filter. A TypeSafe 529 is a retryable 5xx (ADR 013), so it matchesprovider_error, notrate_limited. The default when omitted is["provider_error", "rate_limited", "timeout", "circuit_open"], exactly the ADR 005 behaviour.context_lengthandcontent_filterneed each provider to classify its upstream error into a newProviderErrorvariant; a provider that cannot classify leaves the error as a plain client error and nothing fails over (first release: OpenAI and compatible hosts, Anthropic, Google/Vertex, Bedrock, Mistral, Cohere). Never a trigger: other client 4xx errors, a client cancellation (never touches a breaker, ADR 006), or any failure after the first streamed byte (ADR 004/005). Retries still happen inside each leaf first; the[resilience].total_timeout_msdeadline bounds the whole tree. -
switchconditions (when). Attributes:group(the key’s budget group, ADR 009),metadata.<key>(fromx-lumen-metadata, ADR 002), and request factshas_images,has_tools,stream,input_tokens(the ADR 003 estimate, computed lazily only when a rule references it) anddocuments(rerank). Not the virtual key id. Operators: bare value (equality),in,ne,gt,gte,lt,lte,regex(compiled at load). Keys in onewhenare ANDed;any = [ ... ]is a nestable OR. A missing attribute makes its comparison false; an attribute the capability never has is a validation error. -
Per-target overrides.
overrides = { set, default, drop }on a target, applied to the parsed request struct (not JSON paths) against a per-capability allow-list of fields (chat sampling and limit fields, embeddimensionsandencoding_format, reranktop_n, none for systemone). Unknown fields, fields outside the allow-list and wrong types are validation errors. Order: the requested virtual model’spresetfirst, then target overrides from the outermost virtual model inward, so the level closest to the foundation leaf wins. -
Chat presets.
preset = { system_prompt, system_prompt_mode, overrides }on a chat virtual model. Modes:prepend(default),replace,if_absent. The prompt is at most 32 KB, not blank, and counts in tokens and cost. A preset only applies when its own virtual model is the requested id; on a composed model it is ignored and validation warns. -
SystemOne rerank remap. A
remapon a target of a rerank virtual model, pointing directly at a foundation model that servessystemone, converts/v1/rerankinto SystemOne calls. Strategies:noul(default, one noul per document),score(one score per document, normalisedlevel / (levels - 1)),composite(1 to 8 weighted noul questions per document, weighted mean),choice(one question whose options are the documents; must fit one call, at most 255 documents, elseLM-1001before any upstream call). A static operator-authoredcontextis sent in the state next to the query. Batching and truncation follow the ADR 013 amendment. Accounting:usage.total_tokensis the sum of Jev’s reportedinput_tokens, cost uses the SystemOne model’scost_per_1m_input. Preset and remap text is operator-authored config: never client-supplied, never logged, never echoed in errors. -
Compiled at load, executed per attempt. On boot and reload the virtual models compile into a plan held in the resilience policy’s ArcSwap snapshot (replacing today’s
fallbacksmap); an in-flight request keeps the snapshot it started with. Leaves resolve through the provider registry by foundation id at request time; a leaf the registry no longer knows is skipped (the primary’s miss is still the routing error), never a 500. The decide phase is pure (no I/O, no allocation beyond a small inline vector): it walksSwitchandSplitnodes and flattens the result into an ordered attempt list, each attempt carrying its governing trigger set and route path. The existing ADR 005 executor then runs the list; its only change is that moving to the next attempt is decided by that attempt’s trigger set. TheCancellationTokenflows unchanged and a remap’s concurrent Jev calls share it. A criterion bench targets under 1 us for a 3-level plan containing a regex rule. -
Old fields removed, migration provided.
fallbackson a model, the[providers.models.rerank]block, and atypesafemodel declaringrerankare validation errors that name the model, point tolumen config migrateand print the converted snippet.lumen config migraterewrites old configs so clients keep working unchanged: a modelMon providerPwithfallbacksbecomes foundationP/Mplus a virtualM(fallbackoverP/Mand the old fallbacks); atypesafererank model becomes a virtual rerank model with anoulremap onto the provider’s SystemOne foundation model. File mode backs up to<file>.bakand writes atomically; DB mode writes a newconfig_versionsrow;--dry-runprints the result.
Consequences
- Observability. New
x-lumen-routeresponse header with the path taken (e.g.acme/chat>acme/chat-eu>mistral-large), a newusage_log.routecolumn (migration 0010), a new Prometheus counterlumen_virtual_model_requests_total{virtual_model, model_used}, counted for served requests (virtual ids are operator-defined, so cardinality is bounded), andvirtual_modelandroutetracing span fields (never prompts, presets or remap text).x-lumen-model-usedandusage_log.model_usedstay the foundation leaf that served, so tokens and cost are still booked to it (for a remap, the SystemOne model).GET /v1/modelslists virtual models with"virtual": trueand their single capability; foundation entries gain"virtual": false. - Request-path error codes. Unknown id
LM-2001, wrong capabilityLM-2002, exhausted attemptsLM-3004/LM-3020or the last upstream error, invalid configLM-1001on an admin write (boot error otherwise). Two codes are new (amended 2026-09-30, PR review): a classified context-length refusal that no target absorbed isLM-2012and a content-policy refusal isLM-2013, bothinvalid_requestwith the upstream 4xx status and the provider name, instead of theLM-3003502 every other upstream 4xx still gets (rule 8: the input is the client’s to fix, the provider did not fail). New cases are listed indocs/errors.md. - Admin API.
/admin/config/virtual_models(list), and per-idGET,PUT,DELETEwith the ADR 012If-Matchrule, plus a read-only/planroute that shows the fully resolved tree. Deleting a provider, foundation model or virtual model that a virtual model still references isLM-1001. - Breaking change, visible side effect. Renamed foundation ids change
usage_log.model_usedand Prometheus model labels (e.g.gpt-4obecomesopenai/gpt-4o). A model with several capabilities keeps only one virtual model under its old id (the first declared capability); a call for another capability returnsLM-2002until the operator adds a second virtual model (it still works throughP/M, without fallback). The CHANGELOG flags this with the migration steps. - Performance. The request path gains one hash lookup plus O(depth +
rules) of decide work and drops today’s per-request clone of a
Vec<String>of fallback ids. No lock, database access or shared mutable state is added. - Non-goals. Model-driven routing (
classify,semantic, spec 2); live-state routing (least-busy, latency or throughput sorting, per-target RPM/TPM caps, sticky sessions); per-request routing in the body (OpenRoutermodels/provider, Portkey inline configs); a global rewrite-rule layer; named reusable remap templates (remaps are inline on a target); presets for capabilities other than chat; per-key model allow-lists and per-tenant BYOK.
Amendment (2026-10-08, ADR 017)
A rerank remap targets any model serving decisions, not only Jev. The
noul strategy is renamed predicate; noul stays accepted as an alias and
lumen config migrate rewrites it.