Router Backends & Embedded Services — architecture contract (ADR)
Status: Accepted · Context: #5670, #5603 · Contract:
domain/routing/routerBackends.ts(typed registry — code lands with #5868)
This ADR pins down how ts (native), bifrost, cliproxy, 9router, and
VibeProxy-compatible engines relate to each other, so contributors stop
conflating two things that are architecturally distinct. It documents the typed
registry introduced by the router-backend-registry work as the single source of
truth for that model.
The core distinction — two orthogonal axes
An engine's role is described by two independent axes, encoded together in the
registry's RouterBackendDefinition:
- Lifecycle (
RouterBackendLifecycle) — how the engine runs:in-process— runs inside the OmniRoute Node process (the native TS pipeline).supervised— a local child process OmniRoute installs/starts/stops/health-checks viaServiceSupervisor, then consumes as a provider connection.external— an HTTP endpoint OmniRoute dispatches to but does not manage (configured by an env base URL).disabled— registered but not selectable.
- Selection axis (relay routing backend) — whether the relay dispatches to it:
RelayRoutingBackend = "ts" | "bifrost" | "auto"insrc/app/api/v1/relay/chat/completions/routingBackend.ts.
The mistake to avoid: treating "embedded service" and "routing backend" as one
list. They are not. A supervised engine (9router/cliproxy) is a provider
connection consumed by the native pipeline, not an alternate relay dispatch
backend. bifrost is the reverse — a relay dispatch backend that (historically)
was external-only.
The registry — single source of truth
The domain/routing/routerBackends.ts contract (code lands with
#5868) declares every engine once, with its
lifecycle, capabilities, service identity, default port, health config, and
telemetry support. Consumers look engines up via getRouterBackend(id),
listRouterBackends(), and listRouterBackendsByCapability(cap) instead of
special-casing each sidecar.
| Backend | Lifecycle | Service (axis A) | Relay backend (axis B) | Health | Default port |
|---|---|---|---|---|---|
ts | in-process | — | ts (native) | — | — |
bifrost | external¹ | —¹ | bifrost / auto | /health | — |
cliproxy | supervised | cliproxy | — (provider) | /v1/models | 8317 |
9router | supervised | 9router | — (provider) | /api/health | 20130 |
vibeproxy | external | — | — (provider adapter) | /v1/models | — |
¹ Bifrost's promotion to a supervised embedded service (installable/startable
from /api/services/bifrost/) is tracked in
#5817; until it merges,
Bifrost is external-only (reachable solely via BIFROST_BASE_URL).
capabilities (chat, responses, streaming, tools, vision,
oauth-backed, dashboard-embed, model-sync, native-hot-path) let callers
filter by what an engine can actually do rather than hard-coding per-id branches.
Axis A — embedded services (supervised process side)
- Registry of supervised processes:
src/lib/services/bootstrap.tsSERVICES[](today:9router,cliproxy). - Lifecycle owner:
src/lib/services/ServiceSupervisor.ts—start()spawns the child, gates onwaitForHealthy(), taps stdout/stderr into a ring buffer;stop()SIGTERM→SIGKILL; all serialized under a lock. - State union (
src/lib/services/types.ts):not_installed | stopped | starting | running | stopping | error, plus an orthogonalHealthState = healthy | unhealthy | unknown. - Why a separate process (not an in-proc SDK)? Process isolation is what makes
install/start/stop/health/logs independently controllable per sidecar and lets the
loopback spawn-guard apply. Modeling an in-proc adapter is future work — the
native-hot-pathcapability flag is where that would be expressed.
Lifecycle route contract (/api/services/<tool>/…)
Status codes are state/verb/path-specific by design — this is the contract, not inconsistency:
| Call | Condition | Status |
|---|---|---|
POST .../start | service not_installed | 409 (precondition) |
POST .../stop | already stopped | 200 (idempotent no-op) |
GET .../status | OK | 200 (live ?? row ?? "unknown") |
POST .../start | spawn failure | 503 (transient) |
GET .../status, .../stop | uncaught error | 500 |
GET /api/services/<x>/logs | unknown tool <x> | 404 Service '<x>' not found |
GET .../status?reveal=key | missing X-Reveal-Confirm: yes | 403 (9router only) |
any /api/services/* | caller not loopback/private-LAN | 403 LOCAL_ONLY |
All error bodies are shaped by createErrorResponse() →
{ error: { message, type }, requestId }, where type is derived from the status
(500→server_error, 404→not_found, 409→conflict, else invalid_request) and is
the machine-actionable discriminator. Messages are pre-sanitized
(sanitizeErrorMessage(), Hard Rule #12).
The loopback guard is the most common source of a 403: /api/services/ is in
LOCAL_ONLY_API_PREFIXES (src/server/authz/routeGuard.ts) and
src/server/authz/policies/management.ts rejects any non-loopback / non-private-LAN
caller before auth, because these routes spawn child processes (Hard Rules 15
and 17). Reaching them through a public tunnel is 403 by design.
Axis B — relay routing backend (dispatch side)
Only the relay proxy path /api/v1/relay/chat/completions selects a dispatch
backend; the main /api/v1/chat/completions surface never consults
routingBackend.ts.
- Selection (
resolveRelayRoutingBackend): a single global env toggle —OMNIROUTE_RELAY_BACKEND/RELAY_ROUTING_BACKEND∈ {ts,bifrost,auto}. If unset,autowhen Bifrost is configured+enabled, elsets. - Behavior:
bifrost(forced): Bifrost failure → hard502, no fallback.auto: try Bifrost, on failure/cooldown silently fall through to native.ts/ post-fallback: the nativeopen-ssetranslator/executor pipeline.
- Cooldown: per-
baseUrlfailure cooldown inbifrostCooldown.ts.
Selection is all-or-nothing at the relay level today — there is no per-provider
or per-request engine swap on release/v3.8.43. The per-request gate is being added
by the sidecar-manifest work
(#5869 manifest +
#5870 shouldTryBifrostForRequest),
which lets auto route only manifest-eligible providers through Bifrost.
Dashboard integration
The services dashboard polls GET /api/services/<tool>/status every 5s via
src/app/(dashboard)/dashboard/providers/services/hooks/useServiceStatus.ts,
returning { tool, state, pid, port, health, installedVersion, latestVersion, updateAvailable, autoStart, … }. There is no shared availability-context provider —
each component calls the hook per tool. On !res.ok the hook currently surfaces a
bare HTTP <status>; mapping the error.type field to a human explanation is a
tracked UX improvement, not a contract change.
Consequences
- New engines register once in
ROUTER_BACKENDS; consumers gain them via capability queries without new per-id branches. - "Is this a service or a routing backend?" is answered by the
lifecyclefield, not by which list an id happens to appear in. - The Bifrost supervision (#5817) and native hot-path migration (#5670) build on this shared contract instead of special-casing each sidecar.