--- title: "Routing overview" description: "How Obol picks a model provider for an LLM request and a concrete vendor tool for a capability call, and how that configuration reaches the data plane." --- Obol routes two different things, on two different surfaces, with two different mechanisms. | Surface | What is routed | Mechanism | Crate | |---|---|---|---| | `/v1/*` | The client's `model` string to one of your connected model providers | Pattern match on the model name, then ordered fallbacks | `obol-llm`, `obol-route` | | `/mcp` | A `cap.*` tool call to one concrete vendor tool | Filter, score, argmax over the workspace snapshot's capability bindings | `obol-route` | Both are pure in-process functions of the workspace snapshot, the request, and the clock. Neither performs I/O, reads a counter, or queries a database while deciding. That is invariants 3 and 4: Postgres never sits on the streaming path, and policy is compiled data evaluated in-process. Routing decides **which provider executes**. It never decides whether the model chose a sensible tool. Obol's guarantee is that the wrong tool cannot execute, not that the right one is picked. ## Model routing on `/v1/*` `ModelRouter::resolve` (`apps/gateway/crates/obol-route/src/lib.rs`) takes the client's `model` string and the workspace snapshot's `models` list and returns one `ModelSnapshot` plus its price entry and its fallback chain: 1. An exact name match wins outright. 2. Otherwise the first pattern containing `*` that matches, **in snapshot order**. The pattern grammar is deliberately small — `*`, `prefix*`, `*suffix`, or an exact string. There is no cost or quality comparison on this path: a model connection declares a pattern and an `upstream_model`, and the match is the decision. See [Model providers](/routing/model-providers) for how those connections are created and how the fallback chain behaves. Cost enters this path only through admission control, not selection. If the key carries a monthly budget and any eligible model (the primary or a policy-allowed fallback) has no price in the workspace pricebook, the request is refused with `NotReady` rather than reserved at a fabricated zero. ## Capability routing on `/mcp` A tool whose connector segment is the reserved prefix `cap` is a routed capability (ADR-0015, ADR-0026). Everything else — `stripe.create_refund`, `github.create_issue` — takes the direct path unchanged and is **never** rerouted. The call path in `apps/gateway/crates/obol-gateway/src/hooks.rs` is: `RouteRequest::take_from` removes `route`, `requirements`, and `intent` from the tool arguments. `intent` is reduced to a digest and is never a scoring input — a sentence a model wrote must not choose a provider. None of the three reaches the vendor. The remaining arguments must satisfy the capability's canonical input schema before any provider projection. Validators use snapshot data and reject external schema references without network or filesystem access. Invalid input cannot dispatch. `CapabilityRouter::candidate_targets` lists the capability's non-quarantined bindings. Each is put through the Cedar `route` action; the refusals are collected. With no compiled policy, every candidate counts as unauthorized, so the call fails closed. `CapabilityRouter::select` filters, scores, and takes the argmax. It returns the concrete `ToolTarget` and a `RouteDecisionRecord`. The decision is attached to the invocation guard **before** authorization, so a call Cedar later denies still records which provider the router picked. A capability call claims its idempotency slot with the connection segment literally `unresolved` (ADR-0044), so changing a pin cannot mint a second slot for the same idempotency key. Cedar's ordinary `call` decision runs against the selected concrete tool, with argument conditions over the arguments the vendor will actually receive. That decision gates approval, the vault unwrap, and dispatch. After dispatch, the binding shapes the response and Obol validates the canonical output schema, including identity mappings. A response contract failure retains the dispatch and evidence, returns a nonretryable error, and is replayed from the same invocation. A write that already ran is never retried because its response failed validation. Cedar authorizes the concrete tool and never the capability (ADR-0027). A capability name is a routing subject, not an authorization subject. ### What is deterministic Selection is a deterministic argmax, not a sampled draw (ADR-0028). Given the same snapshot version, the same request, and the same authorization outcome, the same binding wins every time. - Candidates are sorted by `binding_id` before scoring, so scoring order is fixed. - Ties on score break on `binding_id`. - The candidate cap of 8 is applied **after** scoring, so a ninth provider is dropped for being worst, not for being alphabetically last. Each dropped candidate lands on the record with reason `candidate_cap`. - Rank and propensity are stamped after the sort. The winner's propensity is exactly `1.0` and every loser's is `0.0` — written down anyway, so a decision taken under a policy nobody recorded is distinguishable later. Score normalization is log-ratio-to-cohort-best, computed at decision time and never stored, so adding a provider cannot retroactively rescale a stored estimate. {/* TODO: ADR-0028 also specifies a staleness explore bonus, a latency divisor, health gates, hedging, and canary ramps. None of those exist in `select.rs` today — the shipped scorer is price, quality, fitness, auth mode, and surface only. Confirm before documenting them as available. */} ### The score `score_binding` in `apps/gateway/crates/obol-route/src/select.rs` computes: ```text Composite score g = w_price/W · (1 − price_penalty) + w_quality/W · quality + w_fitness/W · fitness + w_auth/W · auth + w_surface/W · surface score = g · quality² · max(source, 0.5) ``` The weights are published in the snapshot's `route_policy.weights` and are currently fixed by `compile_snapshot`: | Term | Weight | |---|---| | `price` | 35 | | `quality` | 25 | | `fitness` | 25 | | `auth` | 10 | | `surface` | 5 | {/* TODO: no control-plane API sets these weights; `apps/control/app/services/snapshots.py` writes the same five constants on every compile. Update if a pricebook-style weights endpoint lands. */} Quality is a hand-authored prior in basis points, not a measurement — there is no latency or success-rate signal in the system yet. It appears squared in the score because its job is to keep a broken provider from winning, not to find a marginally better one. `prefer_sources` is a preference with a floor of `0.5`, not a veto; an operator who means "only this source" uses the `only` constraint, which is a filter and says so. ## Where routing configuration lives Everything the selector reads is compiled into one workspace snapshot in the control plane and published to Redis. Nothing is fetched per decision. ```text Snapshot path Postgres (control) Redis Gateway ────────────────── ───── ─────── route_qualifiers ─┐ connections ─┤ compile_ snap:workspace:{ws} ─→ WorkspaceSnapshot connectors ─┤ snapshot() → policy:{ws} (in-process, per replica) workspace_pricebook┤ snap:pair-fingerprint:{ws} capability packs ─┘ (frozen, on disk) ``` `compile_snapshot` (`apps/control/app/services/snapshots.py`) assembles `connections`, `tools`, `models`, `pricing`, `tool_pricing`, `capabilities`, `bindings`, and `route_policy`, hashes the body canonically, and hands it to `publish_workspace_snapshot`. Publication increments the workspace's `snapshot_version` inside the mutating transaction, then writes the workspace/policy pair to Redis outside it, and reads it back for byte equality. Until the published version covers the workspace's current version, dependent operations refuse rather than proceed against a stale data plane. Capability specs and bindings are **not** database rows. They are frozen JSON packs shipped with the image under `packages/connectors/catalog/capabilities/`, loaded once per process, and republished verbatim into every snapshot. See [Catalog](/routing/catalog). ## Operator surfaces | Surface | Purpose | |---|---| | `GET /api/v1/workspaces/{id}/capabilities` | Every capability, with its effect class, derived evidence floor, and binding counts | | `GET /api/v1/workspaces/{id}/capabilities/{capability_id}/bindings` | Every provider bound to one capability, as declared | | `GET /api/v1/workspaces/{id}/route-qualifiers` | The workspace's standing routing rules | | `POST /api/v1/workspaces/{id}/route-qualifiers` | Upsert one rule and republish | | `GET /api/v1/workspaces/{id}/route-decisions` | Decisions the gateway actually took, newest first | | `POST /v1/route` (gateway) | Advisory capability resolver: prose in, capability cards out. Never dispatches | Control **never runs the selector**. The read surfaces serve frozen pack data plus the decisions the gateway already recorded; nothing in the control plane scores a candidate or predicts a winner. A dashboard that simulated selection would be a second implementation drifting against the first. ## Evidence is a filter, never a term The evidence floor is derived from the tool's effect class and the workspace environment, and a binding whose declared ceiling cannot meet it is excluded with reason `evidence_floor`: | Effect kind | `prod` floor | Non-prod floor | |---|---|---| | `delete`, `external_side_effect` | `gateway_observed` | `untrusted` | | `create`, `update` | `broker_attested` | `untrusted` | | `read` | `untrusted` | `untrusted` | Evidence trust is deliberately not a scoring input. If it were, a cheap federated route would beat a native one on price, tie elsewhere, and win deterministically on every call forever — cost would silently buy weaker proof. In production, a `delete` or `external_side_effect` on a federated binding is additionally excluded outright with reason `destructive_federated`. See [Evidence trust](/receipts/evidence) and [Connector tiers](/connectors/tiers). ## Current state Every structurally valid native and federated catalog binding is active by default (ADR-0063). An active binding is a candidate; concrete workspace access, authorization, request requirements, and evidence floors determine whether it can serve a call. Explicit quarantine remains enforced for faulty or disabled bindings. See [Catalog](/routing/catalog). ## Related pages BYOK model keys, patterns, and fallbacks. Specs, bindings, qualifiers, and the browse surface. The `/v1/*` surface itself. How `tools/list` and `tools/call` behave.