AppProxy Worker Mode¶
This document specifies how Continuum Router operates as a Backend.AI AppProxy inference worker — a data-plane node that an AppProxy coordinator controls, aggregating many LLM serving containers behind a single OpenAI-compatible address.
It is the canonical reference for the worker mode, written to be precise enough that any engineer (or agent) can audit or extend each component without re-deriving the protocol.
The worker ships in two frontend modes:
- the legacy wildcard/port worker (appproxy v3, §§1–8): one circuit per frontend slot, Host-based ingress, per-circuit JWTs, model names auto-discovered from replicas;
- the ROUTER worker (§9): a single binding address for the whole cluster, model-name routing, and API-key-based model visibility.
This document specifies the router (worker) side. The ROUTER mode's
companion on the Backend.AI side is BEP-1053: ROUTER Frontend Mode,
which specifies the AppProxy coordinator and Manager changes: the
FrontendMode.ROUTER value, slot-skipping circuits, the /v2/routers/*
management APIs, desired-state persistence, and the key–model–service mapping
surface. Where this document references coordinator or Manager behaviour,
BEP-1053 is the authority.
1. Motivation¶
Backend.AI's AppProxy fronts model services with a transparent L4/L7
proxy worker: one circuit maps to one frontend slot (a port or a wildcard
subdomain) and forwards bytes to the serving container(s) behind it, load
balancing replicas by traffic_ratio.
Continuum Router can take over that inference data plane and add L7, LLM-aware behaviour on top:
- model-name routing and cross-endpoint aggregation behind one
/v1surface; - protocol translation (OpenAI ↔ Anthropic ↔ Gemini), smart routing, prefix/KV-cache-aware routing, disaggregated prefill/decode;
- fallback chains, retries, response caching, Files API.
In worker mode, an AppProxy coordinator drives Continuum Router's backend set at runtime exactly the way it drives a stock worker (registration, heartbeat, circuit assignment), while Continuum Router realises each circuit as an LLM-aware, health-checked, weighted backend pool.
The slot-based shape (one frontend address per deployment) also leaves a gap at the cluster level, which the ROUTER mode (§9) exists to close:
- No single surface. Every deployment has a different base URL. Clients cannot hold one address and pick a model by name, the way every OpenAI-compatible SDK expects.
- No model abstraction. Users address a deployment, not a model. There is no first-class way to publish one model name backed by several deployment endpoints (for A/B testing two revisions, or for balancing one model across resource groups).
- No key governance. A deployment access token grants access to one deployment; there is no API-key surface that scopes which models a consumer may see and use across the cluster.
2. Background: AppProxy architecture¶
AppProxy has three parts (Backend.AI src/ai/backend/appproxy/):
- Coordinator — the control plane. An aiohttp REST server backed by PostgreSQL (the source of truth for workers, circuits, endpoints, tokens). It schedules circuits onto workers and pushes routing changes out.
- Worker — the data plane. Registers with the coordinator, heartbeats, and proxies traffic for the circuits assigned to it.
- Common — shared types, the event bus, and config.
Key entities¶
| Entity | Meaning |
|---|---|
| Worker | A proxy node, identified by a unique authority (shared across HA replicas via a nodes counter). Has a frontend_mode (wildcard/port/router), a protocol (http/h2/tcp/…), a hostname, an api_port, and, for the slot modes, a slot space (port_range or wildcard_domain). status ∈ ALIVE/LOST/TERMINATED. |
| Endpoint | An inference deployment (model service); id == DeploymentID. 1:1 with a circuit. Carries optional health_check_config. On the Manager side this is EndpointRow with replicas, revisions, and scaling state. |
| Circuit | The central routing object pushed to workers. Binds a frontend (a slot, or, in ROUTER mode, nothing but the worker itself) to a list of backend targets (route_info). app_mode ∈ interactive/inference. For inference it carries endpoint_id and runtime_variant. |
| RouteInfo | One backend target inside a circuit — one replica session: kernel_host, kernel_port, protocol, traffic_ratio, session_id, route_id. |
| Slot | A unit of frontend capacity (one port in a range, or one subdomain) used by the WILDCARD/PORT modes. The coordinator allocates slots; the worker honours them. ROUTER mode has no slots (§9). |
ROUTER mode adds three coordinator/Manager-side entities (specified in BEP-1053, consumed by the worker in §9):
| Entity | Meaning |
|---|---|
| Publication | The Manager-defined entity that makes deployments reachable by model name: a primary model name (plus optional aliases) bound to one or more endpoints (each with a split ratio) on one authority, with a control_mode. "Publish/unpublish a model" = create/delete a publication (§9.2). |
| Model API key | A consumer credential (sk-…) presented to the router. Carries a per-model allow-list; the router holds only its SHA-256 hash (§9.7, §9.8). |
| Authority / node | An authority is the logical router identity (HA replicas share it); publications and keys are scoped per authority. A node is one router process under an authority, identified by an ephemeral node_id for liveness (§9.5). |
Frontend modes¶
| Mode | Frontend per circuit | Slot management |
|---|---|---|
wildcard |
a subdomain under wildcard_domain |
coordinator allocates subdomains |
port |
a port from port_range |
coordinator allocates ports |
router (BEP-1053) |
none — all circuits share the worker's single address; the request's model name is the addressing key |
skipped entirely |
Transport (how coordinator and worker communicate)¶
There are three distinct channels:
- Worker → Coordinator: HTTP REST. Registration, heartbeat, deregistration,
and the initial circuit pull. Authenticated with a shared
X-BackendAI-Token: <api_secret>header. - Coordinator → Worker: Redis Pub/Sub (legacy mode) — circuit create/route-
update/remove broadcast on channel
events_all-appproxy, with a worker ack on create. - Coordinator → Traefik: etcd (Traefik mode) — the coordinator writes Traefik dynamic config to etcd and Traefik proxies; the worker is not signalled per-circuit in this mode.
The mode is a coordinator-global setting (proxy_coordinator.enable_traefik).
This distinction drives one of our design decisions (see §4 and §5.5).
ROUTER mode uses no additional channel: key–model–service mappings ride the same two worker channels, broadcast as Redis Pub/Sub events and served as a REST desired-state snapshot on the pull path (§9.9). There is no coordinator→router push connection; the coordinator never dials workers, so the worker keeps working behind NAT or a cluster ingress.
3. Conceptual mapping¶
The inference path maps almost 1:1 onto Continuum Router's existing model (this table is the legacy wildcard worker's mapping; the ROUTER-mode counterpart is in §9.2):
| AppProxy | Continuum Router |
|---|---|
| Worker (authority, frontend_mode, slot space) | the router instance, registered as a worker |
| Endpoint (inference model service) | a model (the set of backends serving it) |
Circuit (app_mode=inference, route_info[]) |
a model → Vec<BackendConfig> mapping |
RouteInfo {kernel_host, kernel_port, traffic_ratio} |
BackendConfig {url: http://host:port, weight ∝ ratio, models: [model]} |
| Slot (subdomain/port) | the ingress addressing key (see §5.2) |
| RoutePool weighted-random + health | WeightedRoundRobin + HealthChecker |
An AppProxy inference circuit is "one model's N replicas, weighted by
traffic_ratio." That is exactly a Continuum Router backend group whose members
share models = [<model>] and carry per-replica weight. The translation is
therefore mechanical, and the shared data plane provides selection, health
filtering, retry, and fallback.
4. Design decisions¶
Five decisions shape the integration:
- Native module, not an external adapter. The integration lives inside
Continuum Router behind Cargo features (§8). Circuits reach the data plane
through the shared hot-reload
config_senderchannel. - Wildcard protocol compatibility. The legacy wildcard worker (§§5–8)
speaks the wildcard inference-worker
protocol as-is. Model names are obtained by auto-discovering each replica's
/v1/models. - Wildcard ingress that honours the slot (legacy). The legacy worker
registers a wildcard slot space and resolves each request to a circuit by
HTTP
Host(subdomain), with model-name aggregation available on a catch-all host. The coordinator-allocated slot is the ingress address, not a vestige (see §5.2). - Both transports. A pull-based reconcile baseline (works in any coordinator mode) plus a Redis Pub/Sub event overlay (legacy-mode support + low latency). Pull is also the backstop for missed events.
- Both modes are supported. The ROUTER frontend mode (§9) is a departure from the slot model: it requires coordinator-side changes (BEP-1053) and replaces per-circuit JWTs with model API keys. Both modes coexist behind separate features and share one coordinator-client / event-codec / reconcile-apply core (§8).
5. Architecture¶
5.1 Component overview¶
wildcard DNS: *.models.example.com ──► continuum-router host
│
external client │ single socket (e.g. :443)
POST https://ep-abc.models.example.com/v1/chat/completions
│ Host: ep-abc.models.example.com
▼
┌────────────────────────────────────────────────────────────────┐
│ continuum-router (one worker, frontend_mode = wildcard) │
│ │
│ appproxy module (feature = "appproxy") │
│ ├── coordinator client (REST: register/heartbeat/pull) │
│ ├── worker service (lifecycle loops) ── circuit registry │
│ ├── reconcile (circuit → BackendConfig → config_sender) ──┐ │
│ ├── events (Redis Pub/Sub subscribe + ack) │ │
│ └── ingress middleware (Host subdomain → model) ──┐ │ │
│ ▼ ▼ │
│ existing pipeline: model router → backend pool ◄── hot reload │
│ (health, retry, fallback) │
└────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
kernel1:port kernel2:port (LLM serving containers = backends)
The integration consists of the appproxy module and one ingress middleware. Circuit
state becomes backend state through the existing hot-reload machinery; request
routing reuses the existing model router.
5.2 Registration and the slot model¶
The router registers as a wildcard inference worker. "Single address" and "honour the slot" are not in conflict: the wildcard domain is the single address, and each circuit's subdomain is a virtual address into the same socket.
Registration advertises a slot space:
frontend_mode = wildcard
wildcard_domain = ".models.example.com"
wildcard_traffic_port = 443 # the router's /v1 socket
hostname = <router host>
available_slots = -1 # wildcard → unbounded; never runs out
accepted_traffics = [inference]
The coordinator allocates one subdomain per inference circuit inside that
domain, and Circuit.get_endpoint_url() produces
https://ep-abc.models.example.com/. The Manager hands that endpoint URL to
users. The operator configures wildcard DNS (*.models.example.com → router)
once.
PORT mode (a port per circuit) would require the router to open and close listening sockets dynamically and is not supported (see §11). Wildcard + TLS is the norm for externally served inference.
5.3 Circuit → backend translation¶
Each inference circuit is translated to one BackendConfig per RouteInfo
replica and applied through the existing runtime-mutation path:
for each circuit assigned to this authority:
model = discover_model(circuit) # from a replica's /v1/models, keyed by endpoint_id
for each route in circuit.route_info:
BackendConfig {
name: "appproxy-<circuit_id>-r<route_id>",
backend_type: Generic, # OpenAI-compatible; Vllm if known
url: "http://{route.kernel_host}:{route.kernel_port}",
weight: weight_from(route.traffic_ratio),
models: [model], # what find_backends_for_model matches
..Default
}
The apply step reuses the admin API's exact pattern
(src/admin_config/backend_api.rs):
let _guard = config_modification_lock().write().await; // serialise with admin API
let cfg = state.current_config(); // re-read under lock
let mut new_cfg = (*cfg).clone();
reconcile new_cfg.backends so that the set of appproxy-* backends
equals the desired set derived from the current circuits;
state.config_sender.send(Arc::new(new_cfg)); // drives hot reload
HotReloadService then diffs old vs new, adds new backends, gracefully drains
removed ones, syncs the health checker, and invalidates the model cache. No
backend-pool code is touched. Backends owned by this module are namespaced
with an appproxy- prefix so reconcile only ever adds/removes its own entries
and never disturbs statically configured backends.
Two existing characteristics make this a good fit:
- Runtime config changes are in-memory only (never written to disk). The coordinator is the source of truth; the router re-syncs on restart via the initial pull. This is the desired behaviour, not a limitation.
- The typed backend pool is not hot-reloaded, which is irrelevant here: serving containers are generic OpenAI-compatible HTTP backends routed through the URL-based pool.
5.4 Ingress resolution (Host/subdomain → model)¶
An Axum middleware resolves the target circuit/model from the request:
- Read the
Hostheader; strip the configuredwildcard_domainsuffix to get the subdomain. - Look the subdomain up in the in-memory circuit registry (owned by the worker service, updated on reconcile/events) → circuit → canonical model.
- Insert an
IngressTarget { circuit_id, model }request extension. - For non-public inference circuits (
open_to_public == false), verify theAuthorization: Bearer <jwt>(HS256 withjwt_secret; the decodedidmust equal the circuit id), matching AppProxy worker auth.
Handlers prefer the injected model over the body model field at the shared
read sites. The request then enters select_backend_with_retry, where selection
resolves the model name against each backend's models list.
Requests to the bare wildcard domain (or a configured aggregation host) skip subdomain scoping and use normal model-name routing across all circuits — this is the cross-endpoint aggregation surface.
Fallback participation (scoped fallback)¶
A registered circuit does participate in fallback.fallback_chains. When a
request resolves to a circuit whose replicas are all down (its route_info is
empty, so it has no live backend), the ingress middleware still pins the
request to the circuit's canonical model and passes it to the normal pipeline.
select_backend_with_retry then finds no backend for that model and
FallbackService takes over, so a chain keyed on the circuit's model (e.g.
vllm-real-poc → gpt-4o-mini) is reached — the "deployment went down, traffic
goes to OpenAI" behaviour. Per-circuit auth (open_to_public, bearer token,
allowed_client_ips) is enforced before this fall-through, so the fallback
path is never an unauthenticated bypass.
The fall-through is scoped: it applies only to a registered circuit. A
request to an unknown subdomain (no circuit in the registry) is still a
404 endpoint_not_found. An unknown subdomain is a circuit identifier, not a
model name, so it does not enter the model-registry / fallback path.
5.5 Update transport¶
The worker keeps its circuit set current through two cooperating mechanisms:
- Pull reconcile (baseline, always on). After registering, the worker
GETs/api/worker/{id}/circuitsand reconciles; it then repeats on a timer (reconcile_interval). This alone is fully correct in Traefik mode (where the coordinator writes etcd and never signals workers) and is the backstop for any missed event. - Redis Pub/Sub overlay (legacy mode + low latency). The worker subscribes
to
events_all-appproxyand applies create/route-update/remove deltas within ~1s, acking creates. This is required in legacy mode: the coordinator blocks up to 15 s on the worker's ack during circuit creation (initialize_legacy_circuit) and raisesE10001 Proxy worker not respondingon timeout. Route updates and removals are fire-and-forget.
Because the four circuit events are all broadcast (Pub/Sub), the worker needs
only SUBSCRIBE (3 inbound) + PUBLISH (1 ack). Redis Streams /
consumer-groups are not required for the circuit lifecycle.
6. Wire protocol reference¶
6.1 Coordinator REST API (worker scope)¶
Base URL = coordinator_url. Every request carries:
X-BackendAI-Token: <api_secret>X-BackendAI-RequestID: <uuid4>
| Method & path | Purpose | Notes |
|---|---|---|
PUT /api/worker |
register / upsert (idempotent by authority) |
returns {id, slots, …}; HA: re-register increments nodes |
PATCH /api/worker/{id} |
heartbeat | body-less; every heartbeat_period (default 10 s); coordinator timeout 30 s |
DELETE /api/worker/{id} |
deregister | decrements nodes; last node → LOST |
GET /api/worker/{id}/circuits |
full circuit snapshot | {circuits: [SerializableCircuit, …]} |
GET /api/circuit/{id} |
one circuit | |
DELETE /api/circuit/{id} |
remove a circuit |
Registration request body (WorkerRequestModel), wildcard mode:
{
"authority": "continuum-router-1",
"frontend_mode": "wildcard",
"protocol": "http",
"hostname": "router.example.com",
"tls_listen": false,
"tls_advertised": true,
"api_port": 8080,
"accepted_traffics": ["inference"],
"filtered_apps_only": false,
"app_filters": [],
"traefik_last_used_marker_path": null,
"wildcard_domain": ".models.example.com",
"wildcard_traffic_port": 443
}
The response includes the assigned id (worker UUID, cached for subsequent
calls) and the computed slots.
6.2 Circuit and route data models¶
SerializableCircuit (the JSON shape returned by the REST snapshot and embedded
in events):
| Field | Type | Notes |
|---|---|---|
id |
UUID | |
app |
string | "" for inference |
protocol |
enum | http/grpc/h2/tcp/preopen/vnc/rdp |
worker |
UUID | hosting worker |
app_mode |
enum | interactive/inference |
frontend_mode |
enum | wildcard/port/router |
port |
int? | set iff frontend_mode == port; null in ROUTER mode |
subdomain |
string? | set iff frontend_mode == wildcard; null in ROUTER mode |
endpoint_id |
UUID? | inference only; in ROUTER mode, the join key for model mappings (§9.6) |
runtime_variant |
string? | inference only |
open_to_public |
bool | skip auth when true (legacy); ignored in ROUTER mode (§9.7) |
allowed_client_ips |
string? | comma-separated CIDRs (legacy ingress; ROUTER mode uses per-key allow-lists, §9.7) |
route_info |
RouteInfo[] | the backend targets |
session_ids |
UUID[] | |
envs |
object | |
created_at / updated_at |
datetime | ISO-8601 |
RouteInfo:
| Field | Type | Notes |
|---|---|---|
route_id |
UUID? | a different route_id on the same host:port means a kernel swap |
session_id |
UUID | required |
session_name |
string? | |
kernel_host |
string? | None → localhost |
kernel_port |
int | 1–65535 |
protocol |
enum | |
traffic_ratio |
float | default 1.0 → maps to backend weight |
Rust serde notes:
- Accept both kebab-case and snake_case input aliases (e.g.
route-idandroute_id); emit snake_case. extra = "ignore"semantics: tolerate unknown fields (#[serde(default)]/ ignore unknown) so coordinator additions never break parsing.
6.3 Redis event envelope¶
All four circuit events are broadcast as a JSON object PUBLISHed to
events_all-appproxy:
{
"name": "<event_name>",
"source": "<agent-id>",
"args": "<base64(msgpack(args_tuple))>",
"metadata": "{\"request_id\":null,\"user\":null}"
}
argsis base64 of a msgpack array. For these events the array elements are strings only — no msgpack ext types, no UUID/datetime/enum encoding at the msgpack layer (those are pre-encoded inside the inner JSON). A Rust impl needs only: JSON object → base64-decodeargs→ msgpack array-of-strings → JSON for each element.metadatais a JSON string with exactlyrequest_idanduser(additional keys make the coordinator's parser raise). Emit{"request_id":null,"user":null}or echo the inboundrequest_id.sourcefor worker-emitted events is"appproxy-worker". It is not used for routing; the worker filters inbound events ontarget_worker_authority.
Event payloads:
name |
Direction | args tuple |
|---|---|---|
appproxy_circuit_created_event |
inbound | (authority, circuits_json) where circuits_json = JSON array of SerializableCircuit |
appproxy_circuit_removed_event |
inbound | (authority, circuits_json) |
appproxy_circuit_route_updated_event |
inbound | (authority, circuit_json, routes_json) (single circuit + RouteInfo[]) |
appproxy_worker_circuit_added_event |
outbound (ack) | (authority, circuits_json) — echo the inbound circuits_json verbatim |
ROUTER mode adds five more event types in the same envelope; they are listed in §9.10.
Worked ack example (authority = "worker01", circuits_json = "[]"):
msgpack(["worker01","[]"]) = 92 a8 worker01 a2 5b 5d → base64
kqh3b3JrZXIwMaJbXQ==, PUBLISHed to events_all-appproxy with
name = appproxy_worker_circuit_added_event, source = appproxy-worker.
The Redis DB index for the event bus is the deployment's "stream" role DB and
must be configured (redis_url / DB selector); confirm it against the
coordinator's Redis profile.
7. Configuration¶
The optional router configuration section is gated by the appproxy
feature (the ROUTER-mode section, appproxy_router, is specified in §9.11):
appproxy:
enabled: true
coordinator_url: "http://coordinator:10200"
api_secret: "${APPPROXY_API_SECRET}" # X-BackendAI-Token
jwt_secret: "${APPPROXY_JWT_SECRET}" # HS256 circuit/bearer verification
redis_url: "redis://valkey:6379/4" # event bus DB (stream role)
authority: "continuum-router-1"
hostname: "router.example.com"
frontend_mode: "wildcard"
wildcard_domain: ".models.example.com"
aggregation_hosts: [] # extra Hosts that skip subdomain scoping
wildcard_traffic_port: 443
tls_advertised: true
heartbeat_period: "10s"
reconcile_interval: "15s"
events_enabled: true # Redis Pub/Sub overlay on/off
Secrets support ${ENV_VAR} interpolation, consistent with backends[].api_key.
aggregation_hosts is optional and empty by default. The bare wildcard apex
(wildcard_domain without its leading dot, e.g. models.example.com) is always
an aggregation surface implicitly; list any additional vanity or aggregation
hostnames here. A request whose Host matches one of these (or the apex) skips
per-circuit subdomain scoping and uses normal model-name routing across all
circuits (§5.4).
8. Module layout¶
The worker is split into a shared core and a per-mode layer. The wildcard/port worker and ROUTER mode share one coordinator-client, event-codec, and reconcile-apply implementation.
src/appproxy/ # top-level: re-exports the stable public surface
├── mod.rs # crate::appproxy::* re-exports; stable events:: facade
├── events.rs # public re-exports for appproxy::events::*
├── common/ # feature = "appproxy-common" (shared core, no legacy deps)
│ ├── mod.rs # module-level docs; reuse-base for ROUTER mode
│ ├── client.rs # CoordinatorClient; REST register/heartbeat/pull; X-BackendAI-Token
│ ├── config.rs # AppProxyWorkerConfig base
│ ├── events.rs # envelope codec (base64+msgpack), EventHandler trait, reconnect subscriber
│ ├── reconcile.rs # circuit → BackendConfig → config_sender (under the lock)
│ ├── status.rs # /status management handler
│ └── types.rs # SerializableCircuit, RouteInfo, enums (dual-alias serde)
├── legacy/ # feature = "appproxy-legacy" (appproxy v3, requires common)
│ ├── mod.rs
│ ├── events.rs # LegacyEventHandler: registry-driven circuit create/update/remove + ack
│ ├── ingress.rs # Host subdomain → IngressTarget middleware; apply_ingress_model_override
│ ├── jwt.rs # verify_circuit_token (HS256); JwtError
│ ├── registry.rs # AppProxyRegistry: subdomain-keyed circuit store
│ └── worker.rs # run_worker: register → pull → heartbeat → reconcile lifecycle
└── router/ # feature = "appproxy-router" (ROUTER mode)
├── mod.rs
├── config.rs # RouterWorkerConfig (no slot/JWT fields; redacting Debug)
├── client.rs # RouterCoordinatorClient: ROUTER registration + router-config pull
├── events.rs # ROUTER-mode Redis event payloads (model/key updated/removed + applied-ack)
├── keys.rs # RouterKeyStore: hash auth, visibility, per-node rate limit, IP allow-list
├── overlay.rs # ROUTER event overlay: subscriber glue + per-node applied-ack
├── reconcile.rs # RouterReconcileState: circuits × publications → backends (+ key set)
├── types.rs # ModelPublication, ModelApiKey, RouterRegisterRequest (wire types)
└── worker.rs # run_router_worker lifecycle; RouterKeyStore registration
The common/ tree is the documented reuse base for ROUTER mode: it builds standalone under appproxy-common and depends on no legacy-only symbol (jsonwebtoken, the subdomain registry, or the Host ingress layer). ROUTER mode plugs its own EventHandler into the same shared subscriber.
Wiring points:
Cargo.toml: four features.appproxy-common = ["dep:redis", "dep:deadpool-redis", "dep:rmp-serde"]is the self-contained shared core (rmp-serdestays here because the msgpack envelope codec is shared); common reconciliation uses the neutral, always-compiled configuration-mutation lock, and ROUTER IP filtering uses the neutral allow-list helper, so neither path requires the Admin API feature.appproxy-legacy = ["appproxy-common", "dep:jsonwebtoken"]is the appproxy v3 worker (jsonwebtokenis legacy-only);appproxy-router = ["appproxy-common"]is the ROUTER frontend mode; andappproxy = ["appproxy-legacy"]is an accepted alias for the wildcard/port worker. None appear infull; official release binaries are built withappproxy-routerenabled (the Release workflow), whileappproxy-legacyis a source-build opt-in and ROUTER mode activates only via theappproxy_routerconfig section. CI checksappproxy-commonwith default features disabled so an accidental Admin dependency cannot return unnoticed.src/lib.rs:pub mod appproxy;.src/core/config/models/config.rs:pub appproxy: Option<AppProxyWorkerConfig>andpub appproxy_router: Option<RouterWorkerConfig>.src/server/mod.rs::build_router: register the worker/statusroute (either feature) and insert the legacy ingress middleware just outside the rate-limit layer (so the resolved model is visible to the rate limiter).src/server/serve.rs: after the hot-reload block, spawnappproxy::run_worker(cfg, state.clone(), shutdown_rx.clone())whencfg.appproxy.enabled(legacy), andappproxy::run_router_worker(...)whencfg.appproxy_router.enabled(ROUTER).src/http/middleware/auth.rs: the dynamic API-auth middleware consults the ROUTER key store when ROUTER mode is enabled (§9.7).src/models/handlers.rs:/v1/modelsvisibility filtering against the published-model set (§9.7).
9. ROUTER worker (appproxy-router mode)¶
The ROUTER worker is the appproxy-router-feature counterpart of the legacy
wildcard worker described in §§5–8. Where the legacy worker uses Host-based
subdomain ingress and per-circuit JWT auth, the ROUTER worker advertises a
single binding address with model-name routing and per-key model
visibility: no subdomain dispatch, no per-circuit JWT, no slots.
This is a deliberate departure from the wildcard design: a wildcard subdomain
per circuit reproduces the one-address-per-deployment shape, and forces
DNS/TLS wildcards onto every deployment. In ROUTER mode the coordinator skips
slot management entirely, Circuit.get_endpoint_url() returns the worker's
single advertised base URL for every circuit, and what the Manager hands to
users is the triple (base URL, model name, API key) instead of a
per-deployment URL.
The coordinator/Manager side (the FrontendMode.ROUTER enum value,
slot-skipping registration, the /v2/routers/* manager APIs, the router-config
events, and the snapshot endpoint) is specified in BEP-1053; a ROUTER
worker cannot register against a coordinator that predates it (§9.13).
9.1 What ROUTER mode provides¶
- A single OpenAI-compatible
/v1endpoint for the whole cluster; clients select a model by name in the request body, never by URL. - A published model is a collection of one or more deployment endpoints, each with its replicas: two-level hierarchical routing (§9.2).
- API-key-based visibility: which models a client can list and invoke is determined by the API key in the request header (§9.7).
- High availability: multiple router workers may register under one authority behind a load balancer; the coordinator keeps them in sync (§9.5).
- The router's data plane applies health checking, weighted selection, retries, fallback chains, and protocol translation to Backend.AI replicas.
9.2 Concepts: publications, keys, and two-level routing¶
The central concept is a published model:
API key ──(visibility)──► model ──(level 1)──► deployment endpoint(s) ──(level 2)──► replica session
- A publication binds a model name (e.g.
llama-4-chat), defined in Backend.AI Manager, to one or more deployment endpoints. Multiple endpoints behind one model name support A/B testing between revisions and load balancing across resource groups; each mapping carries a splitratio. A publication may also declare aliases, additional names that route identically, so one deployment can be exposed under several model names (e.g.gpt-4andgpt-4-internal). - Each deployment endpoint has one or more replica sessions (the circuit's
route_info), weighted bytraffic_ratio. ratiois a non-negative relative weight (it need not sum to 1.0 across a model's endpoints);ratio = 0drains an endpoint: it stays mapped but receives no new traffic (§9.6).- A model API key carries the set of model names it may see and use; primary names and aliases are gated independently. An empty allow-list means the key is unrestricted and may use any published model (§9.7).
The hierarchy maps onto the router's existing entities almost 1:1:
| Backend.AI / AppProxy | Continuum Router |
|---|---|
| ROUTER worker (authority) | the router instance (or its HA replica set) |
| publication (primary name + aliases) | the names listed in BackendConfig.models; each name is a unit of routing and visibility |
| deployment endpoint ↔ circuit | a backend group: the set of backends named appproxy-<circuit_id>-* |
| RouteInfo (replica session) | one BackendConfig {url: http://kernel_host:kernel_port, weight, models: [names…]} |
mapping ratio × route traffic_ratio |
BackendConfig.weight (composed, §9.6) |
| model API key | an in-memory RouterKeyStore entry with a per-key model allow-list |
| RoutePool weighted-random + health | SelectionStrategy + HealthChecker |
| (legacy) slot | (none): the model name is the addressing key |
Conceptually selection is hierarchical: pick a model, then a deployment endpoint, then a replica. Internally the router realizes it as one weighted selection over a flattened replica pool, with the two levels composed into the weights:
- Level 1 — model selection. The request's
modelfield is looked up viafind_backends_for_model, which returns every backend whosemodelslist contains the name, i.e. the union of all replicas of all deployment endpoints mapped to that model. Because the reconcile populates each backend'smodelslist with the publication's primary name and every alias (§9.6), a request naming any alias resolves to the same backend set with no separate alias-lookup step. The API key's allow-list is applied here: a name outside the key's set is rejected with403(and never appears in that key's/v1/models). - Level 2 — replica selection. The configured
SelectionStrategy(weighted round-robin, least-latency, prefix-aware hash, …) picks one backend from the candidate set after the health checker excludes unhealthy replicas. Each candidate's weight composesendpoint_ratio × route.traffic_ratio(§9.6), so traffic splits correctly across endpoints and across replicas within each endpoint.
Flattening is deliberate, and is what makes multi-endpoint models cheap:
- Latency- and cache-aware strategies observe all replicas of a model at once, regardless of which deployment endpoint they belong to.
- If one mapped endpoint loses all replicas, its weight share simply disappears and traffic shifts to the surviving endpoints; A/B arms degrade gracefully with no control-plane round trip.
- Only when every replica of every mapped endpoint is gone does the model
have no backend, at which point
fallback.fallback_chainsmay take over (§9.7).
9.3 Component overview¶
Backend.AI Manager ──(key–model–service mappings)──► AppProxy Coordinator
│ user creates deployments, │ persists desired state
│ defines models & API keys │ (PostgreSQL); schedules
▼ │ circuits; broadcasts
deployment endpoints (replica sessions) │ events (Redis Pub/Sub)
│
external client │
POST https://router.example.com/v1/chat/completions │
Authorization: Bearer <api-key> │
{"model": "llama-4-chat", …} │
│ ▼
▼ single socket (host:port)
┌─────────────────────────────────────────────────────────────────────────┐
│ continuum-router (one worker, frontend_mode = router) │
│ │
│ appproxy::router module (feature = "appproxy-router") │
│ ├── coordinator client (REST: register/heartbeat/pull snapshots) │
│ ├── worker service (lifecycle loops) ── RouterReconcileState │
│ ├── reconcile (circuits × publications → BackendConfig → config_sender)│
│ ├── events overlay (Redis Pub/Sub subscribe + applied-ack) │
│ └── RouterKeyStore (key hashes, visibility, per-node rate limits) │
│ ▼ │
│ existing pipeline: API-key gate → model router → backend pool ◄─ hot │
│ (health, retry, fallback) reload │
└─────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
replica1:port replica2:port replica3:port (LLM serving containers)
There is no ingress middleware and no Host-based scoping. Circuit and mapping state becomes backend and key state through the existing hot-reload machinery; request routing reuses the existing model router, and the API-key gate runs in the dynamic auth middleware (§9.7).
9.4 Lifecycle¶
- Pin
api_keys.mode = blockingbefore any traffic arrives. The worker forces the global API-key gate into blocking mode at startup. When theapi_keyssection is absent it is created empty; existing operator-configured keys are left untouched. This prevents unauthenticated requests from reaching the data plane during the brief window before the initial key set loads. - Register (
PUT /api/worker) with the coordinator, carrying an ephemeral per-processnode_idUUID. Authentication isX-BackendAI-Token, the same as the legacy worker. Transient failures retry with exponential backoff until shutdown. The responseidis theworker_idused in all subsequent REST calls. - Initial pull and reconcile (
pull_state): fetch the circuit snapshot and therouter-configsnapshot (model publications + API-key hashes) in one pass.RouterReconcileStatejoins circuits × publications into the desiredappproxy-*backend set, applies the result through the hot-reload channel, and mirrors the key set into the in-memoryRouterKeyStorethe data-plane gate consults. - Heartbeat loop every
heartbeat_period(default10s):PATCH /api/worker/{id}carryingnode_id. The coordinator usesnode_idto refresh per-node liveness, which is distinct from the sharedauthority-levelworker_id. - Revision-based reconcile loop every
reconcile_interval(default15s): re-pull with the last-appliedknown_revision. A304skips the apply; a200returns fresh data and a new revision. This is the backstop for any missed event. - Event overlay (using the same Redis Pub/Sub subscriber codec as the
legacy worker): four inbound ROUTER-mode events (model-updated,
model-removed, key-updated, key-removed) each funnel through the same
reconcile mutex as the pull path, so events and pull reconciles never
interleave inconsistently. Disabled with
events_enabled: false; the pull loop is fully correct without it. - Graceful shutdown: the heartbeat and reconcile loops stop, the event
subscriber task is aborted, and
DELETE /api/worker/{id}deregisters the node.
9.5 Registration, HA, and per-node identity¶
The router registers as a ROUTER-mode inference worker: a single advertised address and no slot space.
frontend_mode = router
hostname = <advertised host> # e.g. router.example.com (the LB, if any)
api_port = 8080 # the router's socket (data plane + worker API)
traffic_port = 443 # advertised data-plane port; defaults to api_port
node_id = <uuid4 per process> # per-node liveness identity
accepted_traffics = [inference]
port_range = null # no slot space
wildcard_domain = null
The coordinator skips slot accounting entirely: a ROUTER worker's slot
capacity is unbounded (like wildcard, so it is always a valid inference
candidate) and circuits are created with neither port nor subdomain.
How a deployment's circuit reaches this worker is a coordinator concern
(BEP-1053): scaling groups select the coordinator via
ScalingGroupProxyTarget, and pointing several scaling groups at the
coordinator hosting this authority is what lets one router serve multiple
scaling groups behind one model surface.
High availability and load balancing¶
Multiple router workers may serve the same logical router. They register under
the same authority, typically behind an external L4 load balancer whose
address is the advertised hostname. The coordinator delivers the same circuit
set and the same key–model mappings to every node of the authority, so any node
can serve any request. Synchronization across nodes is inherent, not a separate
subsystem: all nodes share one coordinator-side worker identity, Pub/Sub events
fan out to every node, and the authority-scoped pull snapshot reconciles each
node to identical desired state (a late-joining or restarted node catches up
via the full pull on registration). Distinct authorities may also be registered
to one coordinator to partition deployments across independent routers.
Per-node identity and liveness. Each worker process generates an
ephemeral node_id (UUID4) at startup and repeats it on registration and every
heartbeat. The coordinator uses it to track per-node liveness (with TTL
eviction) and to expose per-node health, while traffic routing away from a dead
node stays the LB/VIP's responsibility. The node_id is also echoed in the
config-applied ack (§9.10) so the coordinator's strict-revocation mode can
count per-node acks. It is not persisted; a restart yields a new node_id and
the old one expires.
Selection-strategy behaviour under HA. Because each node runs its own
in-memory selection over the same backend set, deterministic strategies
(e.g. prefix-aware hashing) independently pick the same replica on every node,
so cache affinity is preserved with no cross-node coordination. Stateful
strategies that depend on locally-observed signals (e.g. least-latency) make
each node decide from its own view; this is acceptable but not globally
coordinated. Likewise, per-key rate_limit is enforced per node (§9.7).
9.6 Reconcile: circuits × publications → backends¶
Each inference circuit assigned to this authority is joined with the model
publications and translated to one BackendConfig per RouteInfo replica:
for each inference circuit with an endpoint_id:
referencing = publications whose mappings name circuit.endpoint_id
if referencing is empty: continue # tracked, but contributes no backends
models = union of all_names(p) for p in referencing # primary + aliases, deduped
endpoint_ratio = max(sanitize(m.ratio) for every referencing mapping of this endpoint)
for each route in circuit.route_info:
weight = clamp(round(100 × endpoint_ratio × sanitize(route.traffic_ratio)), 1, 1000)
if the composed ratio is 0: skip # drained replica (no backend emitted)
BackendConfig {
name: "appproxy-<circuit_id>-r<route_id | index>",
backend_type: Vllm if runtime_variant == "vllm", else Generic,
url: "http://{route.kernel_host | localhost}:{route.kernel_port}",
weight,
models, # what find_backends_for_model matches
..Default
}
Behavioural notes, in the order they matter operationally:
- Held circuits and orderless joins. A circuit whose endpoint is not (yet) mapped by any publication is still tracked but contributes no backends; it becomes routable the moment a publication names it. Conversely, a publication that references a circuit not assigned to this authority is held until the circuit arrives. The coordinator normally orders these, but the reconcile does not depend on ordering.
- Empty publications resolve to fallback. A published model whose
mappingsare empty (e.g. after its last endpoint was destroyed; the Manager prunes but does not auto-unpublish, BEP-1053) contributes no backends and behaves like an all-replicas-down model: it resolves to itsfallback_chainsif any (§9.7), otherwise model-unavailable. The name stays listed in/v1/modelsuntil an operator unpublishes it. - Shared endpoints take the max ratio. When several publications map the
same endpoint, the effective
endpoint_ratiois the largest sanitised ratio among them. This keeps drain semantics (all-zero → every replica drained) while never letting one publication's drain starve another that still routes traffic through the same physical replica. - Ratio sanitising. A non-finite or negative
ratio/traffic_ratiofrom the coordinator is coerced to0(drain) rather than propagated, where it would otherwise become a max-weight traffic magnet or panic the float cast. - Weight composition. The composed ratio is scaled by 100 and clamped into
the backend pool's
weightrange (1–1000). A composed ratio of exactly0emits no backend at all (drain).
The apply step is identical to the legacy worker's (§5.3): under
config_modification_lock, rebuild the appproxy-* backend set, and send the
candidate config through config_sender. HotReloadService diffs, drains removed
backends, syncs the health checker, and invalidates the model cache. The same
pass also replaces the key set in the RouterKeyStore and the published-model
list used for /v1/models visibility.
9.7 Request path: key gate, model routing, fallback¶
A request to the single endpoint flows through the router's normal pipeline:
-
API-key gate (dynamic auth middleware,
src/http/middleware/auth.rs). The key is read fromAuthorization: Bearer <key>or the Anthropic-stylex-api-keyheader. The worker holds only the SHA-256 hash of each key (§9.8), so it authenticates by hashing the presented bearer and looking it up in theRouterKeyStore. The store is worker-owned and separate from the legacyApiKeyStore; reconciled keys take effect without touching theapi_keysconfig section. Outcomes:- missing, unknown, or expired key →
401; - key carries
allowed_client_ipsand the peer IP matches no entry (individual IP or CIDR) →403; - per-key
rate_limit(requests/min, enforced per node) exceeded →429; - otherwise an
AuthContextcarrying the key's model allow-list is attached for downstream gating.
- missing, unknown, or expired key →
-
Model visibility and routing.
/v1/modelslists exactlyallowed_models ∩ currently-published-names; an emptyallowed_modelsmeans unrestricted, which lists every published name. Primary names and aliases are gated independently. The body'smodelfield selects the backend candidate set as described in §9.2 (level 1); a published name outside the key's allow-list is rejected with403(enforce_model_access), and the selection strategy picks a replica (level 2). - Fallback participation (scoped). A published model participates in
fallback.fallback_chains: when all of its replicas are down,select_backend_with_retryfinds no backend andFallbackServicetakes over, so a chain keyed on the model (e.g.llama-4-chat → gpt-4o-mini) is reached — the "deployment went down, traffic goes to OpenAI" behaviour. The key gate runs before this fall-through, so fallback is never an unauthenticated bypass. An unknown model name (no publication) is a plain404 model_not_foundand does not enter the fallback path.
Per-circuit JWT bearer tokens (the legacy worker's open_to_public == false
auth) are not used in ROUTER mode; model API keys replace them as the
data-plane credential, and a circuit's open_to_public flag is ignored.
Source-IP allow-listing is likewise expressed per key
(ModelApiKey.allowed_client_ips), not per circuit: with no Host-based circuit
scoping there is no circuit identity at ingress time to attach a circuit-level
CIDR list to.
9.8 Control plane: who defines what, who holds the keys¶
Backend.AI Manager adds a management surface (GraphQL/CLI/WebUI) for two objects, both scoped to a router authority (BEP-1053):
- Publication —
primary name + aliases → [{endpoint_id, ratio}]with acontrol_mode: which deployment endpoints serve a published model, under which names, with which traffic split, and who owns those ratios (manualvsstrategy_managed; the router routes identically either way and preserves the distinction for tooling). - Model API key —
key_id → {token_hash, allowed_models, expires_at, rate_limit, allowed_client_ips}: a credential a consumer presents to the router. The router stores only the hash.
The model API key is distinct from Backend.AI's deployment access token (the per-deployment JWT minted via the coordinator). WILDCARD/PORT frontends use deployment access tokens.
The flow of a mapping or key, end to end:
user/admin Manager Coordinator Router worker(s)
│ define model / │ │ │
│ issue key │ │ │
├──────────────────────► │ PUT /v2/routers/{authority}/… │
│ ├─────────────────────► │ 1. persist (PostgreSQL) │
│ │ │ 2. broadcast event ───► │ apply mapping payload /
│ │ │ (Redis Pub/Sub) │ pull key hash (REST)
│ │ │ ◄── ack (first node) ───┤ → hot reload
│ key shown to user │ ◄── 200 + ack status │ 3. wait ≤15 s for ack │
│ ◄──────────────────────┤ │ │
Responsibilities, precisely:
- The Manager generates the keys; nobody stores the plaintext. Key
material (an opaque
sk-…-style token) is generated by the Manager, shown to the user exactly once, and persisted only as a SHA-256 hash (plus a masked display hint) alongside the owner, the allowed model set, and the expiry. The Manager is the single user-facing surface for issuing, listing (masked), rotating, and revoking keys; zero-downtime rotation is a secondkey_idissued before the first is revoked. - The Coordinator persists the desired state and broadcasts it. The
Manager never talks to a router directly; it calls the coordinator, which
applies each mutation to PostgreSQL first (the desired state) and then
announces it to the authority's workers as a Pub/Sub event (§9.10). The call
is proactive-deploy: the coordinator waits up to 15 s for the first
worker ack (the same pattern
initialize_legacy_circuituses), then returns. It never waits for every node, and on ack timeout the call still succeeds: the persisted state is authoritative and pull reconcile guarantees convergence. Coordinator-side persistence is required, not optional, because router runtime state is in-memory by design and must be replayable whenever a node (re-)registers. - The Router holds key hashes in memory only (the
RouterKeyStore;api_keys.persistence_fileis unused in ROUTER mode) and enforces them on the data plane as described in §9.7.
No recoverable secret rides the event bus — or moves at all after issuance.
Publication events carry their full payload (a publication contains no
secrets), but key events are notifications only, (authority, key_id), and
the worker fetches the key's hash over the authenticated REST snapshot pull
(§9.9). The Redis bus is shared infrastructure; circuit events already transit
it but carry kernel addresses, never credentials, and this design preserves
that property. With hash-only custody the plaintext token never leaves the user
after issuance: the two point-to-point HTTP hops (Manager → Coordinator,
Coordinator → Router pull) carry only hashes, which must run over TLS
or a trusted network and must never be logged. A leaked hash is not
bearer-equivalent (the router hashes what the client presents), so a
coordinator/Manager DB compromise leaks nothing directly usable.
Revocation is two-tier. An explicit key revoke
(DELETE …/api-keys/{key_id}) propagates immediately via the id-only removal
event, applied on receipt with no fetch; a node that misses the notification
converges at its next pull, so the worst-case window on that node is
reconcile_interval. An RBAC-driven implicit revoke (the owner loses access,
so the Manager shrinks the model out of the key) is additionally bounded by the
Manager's periodic reconcile interval. For immediate hard revocation,
DELETE …?strict=true makes the coordinator wait for an ack from every live
node (each ack carries its node_id, §9.10) and report any unconfirmed nodes;
the LB/VIP is still what removes a dead node from traffic.
9.9 Update transport¶
The worker keeps its circuit set, publications, and keys synchronized through two mechanisms:
- Pull reconcile (baseline, always on). After registering, the worker
GETs/api/worker/{id}/circuitsand the ROUTER-mode desired-state snapshot (/api/worker/{id}/router-config), and reconciles by set-diff against its in-memory state. It repeats on a timer (reconcile_interval). This is the backstop for any missed event and what restores the full in-memory state after a restart. A single per-authorityrevisioncovers both publications and keys; the worker passes its last-applied value asGET /api/worker/{id}/router-config?known_revision={r}, and an unchanged revision yields a cheap304 Not Modified(an explicit query param rather than an HTTPETag, since the coordinator's REST layer has no conditional-GET middleware). On change the coordinator returns the combined full snapshot and the worker set-diffs the whole thing. - Redis Pub/Sub overlay (low latency). The worker subscribes to
events_all-appproxyand applies deltas within ~1 s. Circuit, publication, and key events share this bus (§§5.5, 9.10). Every inbound event carries the full state of one entity (or its deletion), so application is idempotent and last-writer-wins, exactly likeappproxy_circuit_route_updated_eventreplacing a circuit's whole route table; there is no version chain to track. On the worker, every event funnels through the same reconcile mutex as the pull path, so events and pull reconciles never interleave inconsistently.
Because all of these events are broadcast (Pub/Sub), the worker needs only
SUBSCRIBE + PUBLISH (acks). Redis Streams / consumer-groups are not
required.
9.10 Wire protocol additions¶
Worker-scope REST (delta over §6.1)¶
| Method & path | Purpose | Notes |
|---|---|---|
PUT /api/worker |
register (ROUTER body below) | same route as legacy; frontend_mode discriminates |
PATCH /api/worker/{id} |
heartbeat | carries {"node_id": …} (legacy is body-less); refreshes the node's liveness entry |
GET /api/worker/{id}/router-config?known_revision={r} |
ROUTER desired-state snapshot | publications + API-key hashes; 304 if r matches the authority's current revision, else 200 full snapshot |
Registration request body, ROUTER mode:
{
"authority": "continuum-router-1",
"frontend_mode": "router",
"protocol": "http",
"hostname": "router.example.com",
"tls_listen": false,
"tls_advertised": true,
"api_port": 8080,
"traffic_port": 443,
"node_id": "b3f1c0de-…",
"accepted_traffics": ["inference"],
"filtered_apps_only": false,
"app_filters": [],
"port_range": null,
"wildcard_domain": null,
"wildcard_traffic_port": null,
"traefik_last_used_marker_path": null
}
traffic_port is the advertised data-plane port (an LB may sit in front),
defaulting to api_port when the config leaves it unset. node_id is the
ephemeral per-process UUID (§9.5); the same value is sent on registration and
every heartbeat. The slot fields are emitted as explicit null, matching the
legacy registration body's convention. The response is {"id": <worker uuid>},
shared across all nodes of the authority.
router-config snapshot response (200):
{
"revision": "42",
"models": [
{
"model": "llama-4-chat",
"aliases": ["llama-4"],
"mappings": [
{"endpoint_id": "3333…", "ratio": 0.7},
{"endpoint_id": "4444…", "ratio": 0.3}
],
"control_mode": "manual"
}
],
"api_keys": [
{
"key_id": "key-abc",
"token_hash": "<sha256 hex>",
"allowed_models": ["llama-4-chat"],
"expires_at": "2026-12-31T23:59:59Z",
"rate_limit": 600,
"allowed_client_ips": ["10.0.0.0/8"]
}
]
}
Serde rules match §6.2: snake_case output, kebab-case input aliases accepted
(models also accepts publications; api_keys accepts keys/api-keys),
unknown fields tolerated. revision is opaque to the worker: it accepts a JSON
string or number, stores it as a string, and only ever compares and echoes it.
Per key, allowed_models empty means unrestricted; expires_at, rate_limit
(requests/min, per node), and allowed_client_ips (IPs or CIDRs) are optional.
control_mode ∈ manual (default) / strategy_managed; ratio defaults to
1.0.
Manager-scope REST (coordinator side, BEP-1053)¶
The Manager manages publications and keys through the coordinator, which
persists and broadcasts them (§9.8). The router never calls these; they are
listed for context. All routes are authenticated with the shared
X-BackendAI-Token secret:
| Method & path | Purpose |
|---|---|
GET /v2/routers/{authority}/models |
list publications |
PUT /v2/routers/{authority}/models/{model} |
upsert a publication: {aliases, mappings: [{endpoint_id, ratio}], control_mode} |
DELETE /v2/routers/{authority}/models/{model} |
unpublish a model (and all its aliases) |
GET /v2/routers/{authority}/api-keys |
list keys (masked via display hint) |
PUT /v2/routers/{authority}/api-keys/{key_id} |
upsert a key: {token_hash, allowed_models, expires_at, rate_limit, allowed_client_ips} |
DELETE /v2/routers/{authority}/api-keys/{key_id}?strict={bool} |
revoke a key (strict waits for all-live-nodes ack) |
Events (delta over §6.3)¶
Five event types ride the same events_all-appproxy envelope:
name |
Direction | args tuple |
|---|---|---|
appproxy_router_model_updated_event |
inbound | (authority, model_json) — the complete publication state |
appproxy_router_model_removed_event |
inbound | (authority, model_name) — removes the publication and all its aliases |
appproxy_router_key_updated_event |
inbound | (authority, key_id) — notification only, no token or hash; the worker fetches the key hash via the router-config snapshot |
appproxy_router_key_removed_event |
inbound | (authority, key_id) — applied immediately, no fetch needed |
appproxy_worker_router_config_applied_event |
outbound (ack) | (authority, node_id, kind, id) — node_id lets the coordinator count per-node acks; the first ack unblocks the default mutating call, and strict revocation waits for every live node (§9.8) |
9.11 Configuration and mode selection¶
The ROUTER worker is configured by a dedicated appproxy_router section,
gated by the appproxy-router feature:
appproxy_router:
enabled: true
coordinator_url: "http://coordinator:10200"
api_secret: "${APPPROXY_API_SECRET}" # X-BackendAI-Token (router → coordinator)
redis_url: "redis://valkey:6379/4" # event bus DB (stream role)
authority: "continuum-router-1"
hostname: "router.example.com" # advertised host (the LB, if any)
traffic_port: 443 # advertised data-plane port; 0 = use api_port
tls_advertised: true
heartbeat_period: "10s"
reconcile_interval: "15s"
events_enabled: true # Redis Pub/Sub overlay on/off
api_secret and redis_url support ${ENV_VAR} interpolation, consistent
with backends[].api_key. There is no frontend_mode knob (it is always
router), no jwt_secret, no wildcard_domain, and no aggregation-host
list: the single endpoint is the aggregation surface. Unknown keys in this
section are a hard parse error (operator-authored YAML, unlike the tolerant
wire payloads).
Which section is present and enabled determines the worker mode:
| Config | Mode |
|---|---|
appproxy section present, enabled: true |
Legacy wildcard/port worker (appproxy-legacy feature) |
appproxy_router section present, enabled: true |
ROUTER worker (appproxy-router feature) |
| Neither present (or both disabled) | No AppProxy worker spawned |
Mutual exclusion. In a binary built with both appproxy-legacy and
appproxy-router, setting both appproxy.enabled: true and
appproxy_router.enabled: true in the same config is rejected at load with a
config validation error. Two workers sharing the same authority would each
register and then clobber each other's appproxy-* backends on every
reconcile pass.
Two global settings are pinned in ROUTER mode:
api_keys.mode = blocking: forced at startup before any request is served. The coordinator-supplied key set arrives on the first reconcile; blocking mode prevents unauthenticated requests from passing through the window before that completes.api_keys.persistence_file: not used. The key set is rebuilt from the coordinator'srouter-configsnapshot on every restart, so configuring a persistence file has no effect in ROUTER mode.
9.12 Security notes¶
- Coordinator auth. All REST calls send
X-BackendAI-Token: <api_secret>; keep the secret in env/secret storage and never log it. The secret must be identical across the AppProxy cluster. - Data-plane auth. Every inference request must present a model API key
(
Authorization: Bearerorx-api-key) issued by the Manager. The key gates model visibility and use (§9.7); per-circuit JWTs (deployment access tokens) are not used, so ROUTER mode needs nojwt_secret. - Key custody (hash-only, SHA-256). No recoverable key material is at rest
anywhere: the Manager shows the plaintext once at issuance; the coordinator
and the router hold only the hash; the router authenticates by hashing the
presented bearer. Key events on the Redis bus are id-only notifications, and
only hashes move over the authenticated HTTP hops (§9.8). Run both hops over
TLS or a trusted network; never log key values (the router redacts
token_hashandapi_secretin debug output). - Client IP allow-lists are per key (
allowed_client_ips, individual IPs or CIDRs), enforced by the key gate with403on mismatch.
9.13 Limitations¶
- Coordinator support is required. ROUTER mode needs the
FrontendMode.ROUTERenum value, slot-skipping registration, the/v2/routers/*manager APIs, the router-config events, and therouter-configsnapshot endpoint on the Backend.AI side as specified by BEP-1053. All of these coordinator capabilities must be available. - Revocation propagation bound (two-tier). Explicit key revocation reaches
workers via a Pub/Sub notification; a node that misses it converges at its
next pull, so the worst-case window is
reconcile_interval(?strict=truetightens this to all-live-nodes-applied). RBAC-driven implicit revocation is additionally bounded by the Manager's reconcile interval (§9.8). - Per-node rate limiting.
rate_limitis enforced per node with no shared counter, so under HA the effective cluster ceiling is ≈rate_limit × live_nodes. It is coarse abuse control, not a global quota. - WILDCARD/PORT slots. A ROUTER worker registers as
frontend_mode = routeronly; per-deployment slot frontends remain the legacy worker's job (run one or the other per process, §9.11). - Interactive apps. Not served. The router registers
accepted_traffics = [inference]only, so interactive circuits stay on stock workers. - Health/load reporting. The AppProxy heartbeat is a bare keepalive; the router does not export per-circuit load or health metrics to the coordinator (its own Prometheus metrics remain available on the router itself).
10. Security¶
(Legacy worker; for ROUTER mode see §9.12.)
- Coordinator auth. All REST calls send
X-BackendAI-Token: <api_secret>. Keep the secret in env/secret storage; never log it. - Data-plane auth. Non-public inference circuits require an
Authorization: Bearer <jwt>whose decodedidequals the circuit id (HS256,jwt_secret). Public circuits (open_to_public == true) skip it. UsejsonwebtokenwithValidation::new(Algorithm::HS256)— never the unverified payload decode that exists elsewhere in the tree. - Client IP allow-list. Honour
allowed_client_ips(comma-separated CIDRs) when present on a circuit. - Shared secrets.
api_secretandjwt_secretmust be identical across the whole AppProxy cluster (coordinator + workers).
11. Limitations¶
(Legacy worker; for ROUTER mode see §9.13.)
- Model-name source. The served model name is auto-discovered from each
replica's
/v1/models; the coordinator does not supply a model name (the integration is continuum-router-only, per decision 2). - PORT mode. Not supported. It would require dynamic per-port listeners; the router registers as a wildcard worker only.
- Interactive apps. Not served. The router registers
accepted_traffics = [inference]only, so interactive circuits stay on stock workers. - Health/load reporting. The AppProxy heartbeat is a bare keepalive; the router does not export per-circuit load or health metrics to the coordinator.