Spec: model routing and failover in Shield’s LLM proxy
Status: APPROVED and built (tasks 1 to 5; task 6 not started). See “As built” at the end. Branch
spec/gateway-routing-failover. Related:docs/spec-openai-compat-chat-completions.md(the proxy this extends),docs/ai-gateway-interoperability.md(Shield inside LiteLLM, Portkey, Kong),core/secret_vault/(provider keys),docs/specs/ai-discovery.md. Origin: the Quilr comparison gap list (gateway routing, failover, cost).
1. Problem & outcome
Shield already proxies model calls: POST /v1/chat/completions (OpenAI
compatible, api/routes_openai_compat.py) and POST /v1/shield/chat/completions
(api/routes_gateway.py). Both send every call to one place:
| Today (verified on main 33f82f8) | Consequence |
|---|---|
One llm_backend.upstream_url from the process-wide config (api/routes_gateway.py:_get_upstream_url), else the built-in model |
No per-tenant choice of provider or model; every tenant shares one upstream |
httpx.AsyncClient(timeout=300), one attempt, no retry (routes_gateway.py:602, routes_openai_compat.py:270, streaming at :237) |
A provider outage or a hung call is the caller’s outage, for up to 5 minutes |
Only the JSON body is forwarded; no Authorization or provider key |
The upstream must be keyless (vLLM, Ollama). OpenAI, Anthropic, Azure and similar cannot be reached through Shield at all |
The request’s model is passed through unchanged |
No aliasing (“smart”, “cheap”, “private”) and no way to move callers between models centrally |
budget_exceeded is a declared webhook event (api/routes_webhooks.py) that nothing emits |
No spend tracking or limits |
| Guard results are computed before the call and thrown away for routing | A prompt Shield has just flagged as containing PII (allowed in monitor or warn mode) still goes to whatever the default upstream is |
Customers who already run an AI gateway keep using it with Shield as the guardrail (LiteLLM plugin, Portkey or Kong patterns). This spec is for the customers who use Shield’s proxy as their gateway and today get no routing, no failover and no provider keys.
Outcome. A tenant defines targets (a provider endpoint, a model name, a
credential, optional prices) and routes (a name callers use as model, an
ordered list of targets, a strategy, and rules). For each call Shield:
- runs the input guardrails once, as today;
- picks the route by the request’s
model, applies its rules (data sensitivity from the guard results, agent, role, a data-class header, prompt size, budget) to choose the eligible targets; - calls the best healthy target and, on a timeout, connection error, 429 or 5xx, the next one, within the route’s limits;
- runs the output guardrails and tool RBAC once, on the answer that came back;
- records which target answered, the failovers, tokens and cost.
Observable success: with OpenAI’s endpoint returning 503, a call to
model: "smart" is answered by the second target with
X-Shield-Failovers: 1; a prompt flagged by the PII guardrail goes only to the
on-prem target even when a cloud target is cheaper; a route over its monthly
budget follows its configured action and raises budget_exceeded.
Non-goals.
- Provider-native wire formats in v1: only the OpenAI Chat Completions format (OpenAI, Azure OpenAI, vLLM, Ollama, Groq, Together, Mistral, OpenRouter, Fireworks, and the OpenAI-compatible endpoints Anthropic and Google offer). Bedrock (SigV4) and native Anthropic Messages are a later adapter.
- Embeddings, images, audio, the Responses API. Chat Completions only.
- Semantic caching, prompt rewriting, load-testing, or matching LiteLLM’s provider list. Customers who need that keep LiteLLM in front.
- A built-in price list. Prices change and differ by contract; the tenant enters them per target (section 3.1). Without prices, cost strategies and budgets are unavailable for that target and say so.
- Routing on
/guardrails/*,tool/check,cap/mintor MCPtools/call. Those endpoints judge content; they never call a model on the caller’s behalf. - Classifying prompt complexity to pick a cheaper model. Optional task 6, kept separate because it needs a model artifact and torch (section 6).
2. Plane & latency contract
- Data plane (
core/app.py): the two proxy endpoints gain routing and failover. This is the proxy path, not the guard path:/guardrails/*,tool/check,cap/mintandtools/callare unchanged. - Latency budget on the proxy: route resolution and target choice add
at most 1 ms (in-process, measured in task 1’s benchmark test as overhead
against today’s code, not as an absolute figure). No Redis read on a request:
the tenant’s routing config is cached in-process for 10 s and refreshed in the
background (stale-while-revalidate), the same lesson as the tool registry
read that cost 26 ms per call at 1,000 tools. Usage and health go to an
in-process buffer flushed every 30 s (pattern of
core/policy_learning/observe.py). - A failover adds the failed attempt’s time. Each target has its own timeout (default 60 s, streaming first byte 20 s) and each route a total deadline (default 120 s), so the worst case is bounded and far below today’s single 300 s wait.
- Admin plane (
admin_app.py): targets, routes, simulation, usage and health views, and the portal tab. Off the proxy path. - A tenant with no routes, or a request whose
modelmatches no route, takes exactly today’s path. Nothing changes for them.
3. Data model
All keys are tenant-scoped by the tenant resolved from the caller’s key
(request.state.tenant_id); a request with no tenant never uses routes.
3.1 Targets: gw_targets:{tenant}
JSON object {target_id: target}, at most 50 targets.
{
"id": "openai-4o",
"name": "OpenAI GPT-4o",
"base_url": "https://api.openai.com/v1",
"flavor": "openai", // openai | azure
"model": "gpt-4o", // sent upstream as "model"
"azure": {"deployment": "", "api_version": ""},
"auth": {"mode": "vault", // vault | passthrough | none
"header": "Authorization", "scheme": "Bearer",
"secret_ref": "shield://openai_prod"},
"location": "cloud", // cloud | on_prem (rules match on it)
"allow_private": false, // an RFC 1918 base_url needs this
"timeout_s": 60, "first_byte_timeout_s": 20,
"price": {"input_per_mtok": 2.50, "output_per_mtok": 10.00, "currency": "USD"},
"enabled": true, "updated_at": 0, "updated_by": ""
}
auth.mode: vaultstores only the reference. The key is materialized on the outbound request bycore.secret_vault.materialize.materialize_headers, and only when the secret’s bindings include the target’s host; the target write is refused when they do not. The key never appears in config, logs, audit or responses.auth.mode: passthroughforwards the caller’sX-Shield-Upstream-Keyheader (bring your own key) to this target only. A failover to a target in another mode does not receive it.auth.mode: nonefor keyless on-prem servers (vLLM, Ollama).
3.2 Routes: gw_routes:{tenant}
JSON object {route_name: route}, at most 100 routes. route_name is what
callers send as model (letters, digits, ., _, -, /; e.g. smart,
private, shield/cheap).
{
"name": "smart",
"targets": ["openai-4o", "azure-4o", "onprem-qwen"], // the eligible set, in priority order
"strategy": "priority", // priority | lowest_latency | lowest_cost | weighted
"weights": {"openai-4o": 70, "azure-4o": 30}, // weighted only
"failover": {"max_attempts": 3, "deadline_s": 120,
"on": ["timeout", "connect", "429", "5xx"]},
"rules": [
{"when": {"flagged": ["pii_detection", "secrets_detection"]},
"use": ["onprem-qwen"]},
{"when": {"data_class": ["restricted"]}, "use": ["onprem-qwen"]},
{"when": {"role": ["contractor"]}, "deny": "Contractors cannot use this model"},
{"when": {"prompt_tokens_over": 60000}, "use": ["azure-4o"]}
],
"budget": {"monthly": 500.0, "currency": "USD",
"at_limit": "fallback", // warn | fallback | block
"fallback_targets": ["onprem-qwen"]},
"enabled": true, "updated_at": 0, "updated_by": ""
}
Rules are evaluated in order; the first that matches decides. use
narrows the eligible set to the listed targets (they must be in
targets); deny refuses the call. Conditions in one when must all hold:
| Condition | Matches when |
|---|---|
flagged |
One of these guardrails did not pass on this request’s input but the request was allowed (monitor mode, or action warn or log). This is the data-sensitivity signal Shield already computed |
data_class |
The caller’s X-Shield-Data-Class header is one of these values |
agent / role |
The resolved agent key or role name is one of these (from the auth middleware, never a raw header) |
location_required |
Shorthand: keep only targets with this location |
prompt_tokens_over |
An estimate of the prompt’s tokens (characters / 4) exceeds the value |
The invariant that makes rules safe: failover and every strategy act only inside the eligible set a rule produced. A prompt routed to on-prem because it carries PII is never sent to a cloud target because the on-prem one failed; if every eligible target fails, the call fails (section 7).
3.3 Usage, spend and health
| Key | Shape | Notes |
|---|---|---|
gw_usage:{tenant}:{yyyymmdd} |
HASH "{route}|{target}" → JSON {calls, ok, errors, failovers_from, input_tokens, output_tokens, cost, latency_ms_sum} |
Written by the flush (30 s); TTL 95 days |
gw_spend:{tenant}:{yyyymm} |
HASH {route} → cost as a decimal string |
Incremented by the flush; read by the config refresh; TTL 400 days |
gw_health:{tenant} |
HASH {target_id} → JSON {state, failures, last_error, last_ok_at, at, process} |
Each data-plane process writes its own view at flush; TTL 1 h; for the portal only |
Circuit state used for decisions lives in each process (no shared state on
the request path). A target opens after 5 failures within 60 s, stays open for
30 s, then lets one probe call through (half-open). Latency for
lowest_latency is an exponentially weighted average per process.
Spend used for budget decisions is the stored month total plus this process’s unflushed spend, refreshed with the config every 10 s. It can therefore exceed the limit by up to one flush interval of traffic across processes; the spec accepts that and the portal says so.
4. API / interface
4.1 Callers (data plane, unchanged endpoints)
Callers keep using POST /v1/chat/completions or
POST /v1/shield/chat/completions with their tenant key, and put a route name
in model. Optional request headers:
| Header | Meaning |
|---|---|
X-Shield-Data-Class |
A label the route’s rules can match (public, internal, restricted, …). It can only narrow where a call goes, never widen it past the route’s targets |
X-Shield-Upstream-Key |
The caller’s own provider key, for targets in passthrough mode. Stripped before logging |
Response additions, when a route was used:
| Where | Field |
|---|---|
| Headers (both endpoints) | X-Shield-Route, X-Shield-Target, X-Shield-Failovers (count), X-Shield-Budget (ok, warn, fallback) |
/v1/chat/completions body |
x_shield.routing: {route, target, attempts: [{target, outcome, ms}], rule, cost}; model is the target’s model name, as OpenAI clients expect |
/v1/shield/chat/completions body |
routing with the same object, next to guardrail_results |
A deny rule, an exhausted route, or a block budget answers like a guardrail
block on each endpoint (200 with finish_reason: "content_filter" on the
OpenAI-compatible one; 403 on the legacy one), with x_shield.routing.reason.
4.2 Configuration (admin plane), /v1/tenant/me/gateway
| Route | Does | Gate |
|---|---|---|
GET /targets, GET /targets/{id} |
List, read (credentials shown as the reference only) | tenant |
PUT /targets/{id}, DELETE /targets/{id} |
Create or replace, delete (refused while a route uses it, 409) | config write; allow_private: true also needs the registry write gate, as for MCP servers |
GET /routes, GET /routes/{name} |
List, read | tenant |
PUT /routes/{name}, DELETE /routes/{name} |
Create or replace, delete | config write |
POST /routes/{name}/simulate {messages, headers, agent, role} |
Runs the input guardrails and the rules and returns the eligible set, the order the strategy would try, and the budget state; calls no model | config write (it runs guardrails) |
GET /usage?from=&to=&route= |
Daily calls, tokens, cost, failovers, errors per route and target | tenant |
GET /health |
The last flushed circuit state per target and process | tenant |
All writes are audited (gateway_target_set, gateway_target_deleted,
gateway_route_set, gateway_route_deleted). Webhook budget_exceeded (the
event that already exists) fires once per route per month when spend crosses
budget.monthly, and gateway_target_down (new) when a target’s circuit opens,
at most once per target per 10 minutes per process.
4.3 Portal
A Model routing tab: targets (health dot, location, price), routes (targets in order, strategy, rules in plain words, budget bar), a simulator (“which model would this prompt use, and why”), and a usage table by day.
5. Security & backward compatibility
- Default: no behavior change. Routing applies only when the tenant has a
route whose name equals the request’s
model.SHIELD_LLM_ROUTING=0disables it process-wide (escape hatch: every call takes today’s path). - Guardrails are not bypassable by routing. Input guardrails run once before routing, exactly as today; output guardrails and tool RBAC run once on the answer that is returned, whichever target produced it. A failed attempt’s partial output is discarded, never returned. Both endpoints keep their fail-closed behavior.
- Rules only narrow.
usemust be a subset of the route’stargets;X-Shield-Data-Classcan only select a narrower rule; failover never leaves the eligible set. A caller cannot name a target directly. - Credentials. Vault references only, bound to the target’s host by the
vault’s own binding check (
_binding_matches);is_self_hostrefuses binding to a Shield plane. Passthrough keys go to that one target and are never logged. Target reads never return a secret. - Egress (SSRF).
base_urlis checked withcore.url_safety.validate_outbound_url(purpose="llm-target")at write and again at call time (DNS rebinding), private addresses only withallow_private, and redirects are not followed (follow_redirects=False). - Body hygiene. Shield sets
modelto the target’s model and drops fields the target flavor does not accept (x_shield, Shield-only fields); the rest of the client body passes through as today. - Cross-tenant. Config, usage, spend and health are keyed by the caller’s tenant; the in-process cache is keyed by tenant.
- Existing tests stay green.
docs/spec-openai-compat-chat-completions.mdrequires the legacy endpoint to stay byte-for-byte unchanged; that holds for every call that matches no route.
6. Packaging & deploy
- New package
core/llm_gateway/:config.py(store, validation, cache),select.py(rules, strategies, budget),health.py(circuit, latency),dispatch.py(call with failover, streaming),usage.py(buffer, flush). - Data plane:
api/routes_gateway.pyandapi/routes_openai_compat.pycallcore.llm_gateway.dispatchat their existing “proxy to the LLM” points and in_build_stream_payload/_stream_chat_completion. - Admin plane:
api/routes_llm_gateway_admin.py, mounted inadmin_app.py.Dockerfile.admingetsCOPY core/llm_gateway/and the route module (guarded bytests/test_admin_dockerfile_imports.py). - No new dependencies for tasks 1 to 5 (httpx is already used).
- Env:
SHIELD_LLM_ROUTING(default on;0ignores routes),SHIELD_LLM_ROUTING_CACHE_S(10),SHIELD_LLM_ROUTING_FLUSH_S(30). - Rebuild the data-plane image and the admin image.
- Task 6 only: the complexity router (ModernBERT,
predict_router.pyandrouter-complexity/in a local checkout, not in the repo) needstorchandtransformersand a published model artifact. It would add acomplexitycondition (low,medium,high) to rules. Not started without its own approval and a latency measurement on the data plane.
7. Failure modes & edge cases
| Case | Behavior |
|---|---|
model matches no route, or no tenant |
Today’s path, unchanged |
| A target times out, refuses the connection, answers 429 or 5xx | Next eligible target, within max_attempts and deadline_s; recorded in attempts |
| A target answers 400, 401, 403, 404, 422 | No failover (the request or the credential is wrong, and another target would likely fail the same way, or leak a bad request further); the error is returned in the endpoint’s error shape, naming the target |
| Every eligible target failed | 502 on the legacy endpoint; on the OpenAI-compatible one an OpenAI-shaped error (type: "upstream_error") listing the attempts. Never a fallback outside the eligible set |
| A rule narrows to targets that are all disabled or open | Same as every target failed; the simulator shows it before it happens |
| Streaming, target fails before the first byte | Failover as above |
| Streaming, target fails after bytes were sent | No failover (the client already has part of an answer); the stream ends with the existing content-filter or error chunk |
| Vault disabled, or the reference is missing or not bound to the host | That target is skipped as credential_unavailable and counted as a failure; the target write is refused up front when detectable |
| Price missing on a target | lowest_cost orders it last; budgets count it as 0 and the portal flags the route as “spend incomplete” |
| Redis down | Cached config keeps serving; if the cache is cold, routes are unavailable and calls take today’s path, logged once per minute. Usage stays in the buffer (capped at 10,000 rows per process, then oldest dropped and counted) |
| Config changed | Seen by every process within the cache interval (10 s) |
| Budget crossed during a burst | Over by at most one flush interval across processes (section 3.3); at_limit then applies |
block budget on a route an agent depends on |
The refusal says the route’s budget is spent; warn is the default at_limit |
| Huge request body | Same limits as today; prompt_tokens_over uses a character estimate, no tokenizer |
| Output guardrail blocks the answer | Blocked as today; no failover to “try another model until one passes” (that would turn routing into a guardrail bypass) |
| Tool calls in the answer | RBAC and kill switch on the returned answer, as today |
Fail-closed where it protects data (rules, eligible sets, credentials, guardrails); fail-open to today’s path only when routing itself is unavailable (no config), which is the behavior every tenant has now.
8. Test plan (Definition of Done)
- Config validation: every field, limits (50 targets, 100 routes), rule
targets must be a subset, bad URLs, private URL without
allow_private, vault reference not bound to the host, passthrough header name. - Selection: each strategy; each rule condition; first match wins;
usenarrows;deny; budget stateswarn,fallback,block; disabled and open targets skipped. - The narrowing invariant: a property-style test over random routes and failures that no attempt ever goes outside the eligible set.
- Dispatch with a fake upstream: timeout, connect error, 429, 5xx fail over;
4xx does not; deadline and
max_attempts; attempts recorded; the answer from the second target goes through the output guardrails; a failed attempt’s body is never returned. - Streaming: failover before the first byte; none after.
- Credentials: vault materialization only for the bound host; passthrough key only to passthrough targets; keys absent from logs, audit and responses.
- Circuit breaker: opens, half-opens, closes; per process; health flush.
- Usage and spend: buffer, flush, cost from prices and usage, monthly spend,
budget_exceededonce per route per month,gateway_target_downthrottled. - Backward compatibility: with no routes (and with
SHIELD_LLM_ROUTING=0) both endpoints behave exactly as before, using the existingtests/test_openai_compat*.pyand gateway tests unchanged; an overhead benchmark for resolution (at most 1 ms added). - Admin routes: gates (route classification test covers the new writes),
audit, simulate calls no model, credentials never returned;
Dockerfile.adminimport guard; portal escaping and empty state. - Full suite green in a clean venv; CI
pytestpasses.
Tasks (one branch, one PR, one commit per task)
- Targets, routes and priority failover (non-streaming).
core/llm_gateway/config, select (priority only, no rules yet), dispatch, credentials (vault, passthrough, none), SSRF checks; both endpoints; response headers androutingfields; audit metadata; admin CRUD and simulate;Dockerfile.admin. Tests 1, 2 (priority), 4, 6, 9, 10. - Streaming, health and strategies. Failover before the first byte, the
circuit breaker,
lowest_latencyandweighted, the health flush andGET /health,gateway_target_down. Tests 5, 7. - Rules.
flagged,data_class,agent,role,location_required,prompt_tokens_over,deny; the narrowing invariant. Tests 2 (rules), 3. - Cost and budgets. Prices, usage buffer and flush,
lowest_cost, monthly spend,at_limit,budget_exceeded,GET /usage. Test 8. - Portal tab. Targets, routes, simulator, usage. Test 10 (portal).
- Optional, separate approval: complexity condition from the ModernBERT router, after the model is published and its data-plane latency measured.
As built (tasks 1 to 5)
- Simulator. The admin image carries no input guardrail pipeline (it runs
on the data plane), so
POST /routes/{name}/simulatedoes not run guardrails: the request names the guardrails that would flag the prompt (flagged), with the data class, role, agent and a sample for its length. - A matched route never falls back. If the route matches but cannot be evaluated (a bug), the call is refused like a block, not sent to the default upstream. Only a missing routing config (cold cache with the store down) takes the default path.
- Rules.
location_requiredis not a test: it narrows the targets of a rule that matched, and on its own it always applies. All other conditions present must hold. The first matching rule decides. - Budgets apply after the rules:
fallbacknarrows the rule’s eligible targets tofallback_targets; if that leaves none, the call is refused. Spend used for decisions is the stored month total from the cached snapshot plus this process’s unflushed spend, so it can trail by one flush. - Streaming. Failover stops once a target answers with a status line;
the first-byte timeout bounds the wait for it. A routed stream that breaks
after starting ends with an
upstream_errorevent and[DONE], and is counted asstream_broken. A stream without reported usage is costed from characters (4 per token) and counted inestimated. The existing SSE proxy, with its mid-stream output guardrails, is reused for routed streams. - Health outcomes that count as failures: timeout, connect, 429, 5xx, bad response, credential unavailable, unsafe URL. A 4xx does not.
- Webhooks.
gateway_target_downis a new event;budget_exceeded(declared before, never sent) now fires once per route per month across processes viaHSETNX gw_budget_alerted:{tenant}:{yyyymm}. - Body. Shield drops
prompt,system,x_shieldandrun_id, setsmodelto the target’s, and sends the client’s ownmessages(not the default system message the legacy path adds). - Portal. Targets are edited in a form, routes as JSON. Checked in a browser against the admin app with seeded targets, routes, usage and health.
- Not done: task 6 (complexity condition) needs its own approval.