--- title: "Router Backends & Embedded Services (ADR)" version: 3.8.43 lastUpdated: 2026-07-02 --- # Router Backends & Embedded Services — architecture contract (ADR) > **Status:** Accepted · **Context:** [#5670](https://github.com/diegosouzapw/OmniRoute/issues/5670), > [#5603](https://github.com/diegosouzapw/OmniRoute/issues/5603) · **Contract:** `domain/routing/routerBackends.ts` > (typed registry — code lands with [#5868](https://github.com/diegosouzapw/OmniRoute/pull/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`: 1. **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 via `ServiceSupervisor`, 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. 2. **Selection axis** (relay routing backend) — _whether the relay dispatches to it_: `RelayRoutingBackend = "ts" | "bifrost" | "auto"` in `src/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](https://github.com/diegosouzapw/OmniRoute/pull/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](https://github.com/diegosouzapw/OmniRoute/pull/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.ts` `SERVICES[]` (today: `9router`, `cliproxy`). - **Lifecycle owner:** `src/lib/services/ServiceSupervisor.ts` — `start()` spawns the child, gates on `waitForHealthy()`, 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 orthogonal `HealthState = 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-path` capability flag is where that would be expressed. ### Lifecycle route contract (`/api/services//…`) 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//logs` | unknown tool `` | **404** `Service '' 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, `auto` when Bifrost is configured+enabled, else `ts`. - **Behavior:** - `bifrost` (forced): Bifrost failure → hard `502`, no fallback. - `auto`: try Bifrost, on failure/cooldown silently fall through to native. - `ts` / post-fallback: the native `open-sse` translator/executor pipeline. - **Cooldown:** per-`baseUrl` failure cooldown in `bifrostCooldown.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](https://github.com/diegosouzapw/OmniRoute/pull/5869) manifest + [#5870](https://github.com/diegosouzapw/OmniRoute/pull/5870) `shouldTryBifrostForRequest`), which lets `auto` route only manifest-eligible providers through Bifrost. ## Dashboard integration The services dashboard polls `GET /api/services//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 `; 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 `lifecycle` field, 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.