-
-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Router Backends
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.
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 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.
-
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.
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.
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.
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.
- 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.
OmniRoute · Website · npm · Docker Hub
- Setup Guide
- User Guide
- Features
- Quick Start (Docker)
- Electron Desktop App
- Termux (Android)
- PWA Guide
- MCP Server
- A2A Server
- Agent Protocols
- OpenCode Plugin
- Webhooks
- Cloud Agents
- Skills
- Memory
- Evals
- Gamification
- Guardrails
- Compliance
- Error Sanitization
- Public Credentials
- Route Guard Tiers
- Stealth Guide
- CLI Token Auth