How it works Features Docs Compare Blog GitHub
Self-host it Sign up
Documentation

Routing & auto-routing.

polyrouter's routing engine is a layered, degradable pipeline. Layer 0 explicit routing is the reliable core that always works; the automatic layers refine model:"auto" and always fall back to explicit or default. L1 structural is on by default; L2 semantic and L3 cascade are opt-in. Alongside the difficulty band, an auto request also records a workload class — what kind of work it is — which can claim the request outright or scope the bands to that class. Every smart-layer fault degrades to exactly the path it would have taken with that layer disabled. The smart path never fails or stalls a request.

Maintained by Anthony Izzo · Last updated

How polyrouter resolves a request Layer 0 explicit routing always wins. For model auto, L1 structural runs by default with L2 semantic and L3 cascade opt-in; any layer that is disabled, skipped or faulted falls through to the next, and to your default tier when none is left. REQUEST IN Layer 0 — explicit A named model or tier always wins. auto L1 structural On by default. Sub-millisecond, local. L2 semantic Opt-in. Refines only what L1 finds unclear. L3 cascade Opt-in. Try cheap, escalate on failure. DISABLED · SKIPPED · FAULTED — FALL THROUGH Your default tier When no layer is left, the request still resolves. A faulted layer never fails a request. explicit still applies
A request enters at explicit routing, then flows through the smart layers, degrading safely to your default tier.
Decision layers explicitmodel field only headerx-polyrouter-tier · header rules workloadauto_workload class claim structuralL1 semanticL2 cascadeL3 defaultcatch-all

Routing precedence

Layer 0 is a pure function — no database, no dependency-injection, no clock — and it never faults. It resolves in five phases, first match wins. Whatever phase decides, your budgets and cost recording still apply — and anything that resolves to a tier gets that tier's ordered fallback chain. A target that names a single model — the model field, but equally a header rule, a default rule, a band rule or a workload rule carrying model:<id> — resolves to one attempt with no fallback.

  1. model fieldA direct model id, a provider-prefixed id, or a tier name in the request body — honored first.
  2. x-polyrouter-tierThe built-in tier header. Highest-precedence header — beats every other header rule.
  3. header rulesRules on other headers, matched in priority order — the first match wins.
  4. default ruleThe system's configured default routing rule, if one is set. It records decision_layer=default — the same layer phase 5 records — so an auto request that lands here still hands off to the smart layers rather than stopping at the rule's target.
  5. default tierThe guaranteed catch-all — the default tier's entry chain. model:"auto" that reaches here — or phase 4 — hands off to the smart layers.

The model field takes three forms

FormExampleResolves to
Direct model idgpt-5That exact model on the single provider that exposes it — matched independently of tiers, with no fallback (a single target). If two providers expose the same id the request is a 404 ambiguous_model; qualify it as <providerId>:<model>. If the string matches no model but is a tier key, it resolves as that tier.
Provider-prefixed<providerId>:gpt-5That specific provider's copy of the model. <providerId> is the provider row's UUID — call GET /v1/models for the exact qualified ids.
Tier namefastThe named tier's ordered entry chain.
Note x-polyrouter-tier has structural precedence: a non-empty value that resolves to a tier or remap rule wins regardless of any other header rule's priority — it beats every mechanism except the model field. It resolves in two sub-steps: first tier-header remap rules that match the sent value, then a direct lookup of an owned tier by that name. A matched-but-broken tier surfaces a clear error rather than silently rerouting. A value that matches neither a remap rule nor an owned tier key is advisory — the request silently falls through to the remaining phases and the default tier, with no error and no header recorded. The value match is exact and case-sensitive; tier keys are lowercase slugs.

Tiers & fallback chains

A tier is a named routing target. Exactly one tier — default — is seeded for you; fast and cheap below are tiers you would create. Separately, auto_low, auto_high and auto_workload are routing-rule match types pointing at a tier or model you configure — they are not tiers. The two band types may additionally carry a workload-class scope; see Workload routing. Each tier holds an ordered chain of up to five routing entries: position 0 is the primary, positions 1–4 are fallbacks. Manage them on the Routing page or via /api/routing/tiers and /api/routing/tiers/:tierId/entries — GET returns the ordered chain, PUT replaces it atomically; there is no per-entry create or delete.

Fallback chain · walked in position order until one responds
pos 0 · primary claude-sonnet-5
pos 1 · fallback gpt-5-mini
pos 2 · fallback llama-3.3-70b
Up to 5 entries — the chain is walked in position order. drag-to-reorder in the dashboard

What triggers a fallback

Fallback-eligible errors
auth rate_limit unavailable unknown_model credential circuit open
The chain advances to the next entry — an upstream that's down or throttled shouldn't take the request with it.
unknown_modelis model-specific, so a model retired at one provider must not fail the request; credential (a revoked OAuth grant or an identity-provider outage) is eligible but breaker-neutral; circuit open skips the entry before the adapter is even built.
Terminal errors — no fallback
bad_request caller cancelled unclassified
Returned as-is. bad_request is the only provider kind that never falls back — a malformed request fails the same way on every entry. Caller cancellation is not a fault, and an error the router cannot classify is deliberately never retried.

How the chain is walked

1
Classify the error
Only fallback-eligible kinds advance; a terminal error stops here.
2
Check commit state
If the response already committed to a stream, no fallback — the model is locked.
3
Check the circuit breaker
If the next provider's breaker is open, skip that entry and keep walking.
4
Try the next entry
Walk the chain until one responds or the entries are exhausted.
Note Never swapped mid-stream. Fallbacks happen freely before the first token. Once streaming begins the model is committed — an upstream failure ends the stream with a clear error, never a silent swap.

The auto_high & auto_low band targets

The smart layers don't pick a model themselves — they pick a band, and each band points at a target you configure: normally a tier (so the band inherits its fallback chain), or a single model if you pick one, in which case there is no chain behind it. auto_low is the cheap tier; auto_high is the strong tier. Both live in the Band targets card of the Routing page's Auto section, each showing its resolved chain (primary + fallback count) and flagging any degraded state — cascade needs both bands usable. The same card carries a per-workload strong/cheap pair per class: when a request has a workload class, that class's scoped rows decide for it and the generic rows apply only where no scoped row exists — see Workload routing.

Smart layers & model:"auto"

Send model:"auto" and — if Layer 0 falls through to the default tier — the smart layers take over. Layer 1 classifies first, producing two verdicts: a difficulty band and a workload class. A class with a target of its own claims the request there and then; otherwise the band resolves, Layer 2 refines what is still ambiguous, and Layer 3 cascades. The whole stack lands on the default tier if every layer is disabled or unsure. Automatic routing is enhancement; it never becomes a new way for a request to fail.

Gating A layer runs only when all of these hold: the instance has it enabled; for Layer 2, the whole classifier is ready; the tenant has opted in (default-on when unset, except the learning loop which defaults off); and the request is model:"auto" that fell through Layer 0 to the default tier. The semantic workload source adds one more condition — the five workload centroids built at boot — but no separate toggle: the tenant's Layer-2 switch governs both uses of the same embedding.
explicit Layer 0 always on · never faults
Resolves the model field, tier header, header rules, and default rule. A named target routes immediately — only model:"auto" on the default tier continues down.
auto fell through to default ↓
structural Layer 1 on by default · sub-ms · $0
Scores cheap, language-neutral features once, and reads two verdicts off them: a difficulty band and a workload class. highauto_high, lowauto_low (decision_layer=structural) — scoped to the class when that class has its own band pair; ambiguous continues.
classified ↓
workload Workload claim unset classes change nothing
If the detected class has an auto_workload target, it claims the request here (decision_layer=workload) — the band is never resolved, and Layer 2 and the cascade never run. none, an unset class, or an unresolvable target simply continues.
no claim · L1 ambiguous ↓
semantic Layer 2 opt-in · new · CPU ~5–20 ms
Embeds the request text once and classifies it against anchor centroids. Band refinement touches only the ambiguous slice — a confident band routes via auto_high/auto_low (decision_layer=semantic), otherwise it continues. The same vector also feeds the semantic workload source (research / writing) — one embed per request, never two.
L2 ambiguous or skipped ↓
cascade Layer 3 opt-in · cheap-first
Runs the cheap tier, grades the result with a quality score, and escalates to the strong tier only if it fails — auto_high → fallback → default. Both legs stay inside the request's workload scope when that class has its own band pair, and the reason ends with scope=<class>.
every layer off or unsure ↓
default Default tier guaranteed catch-all
Where auto lands when the smart layers are unavailable or unsure. The request is never left without a route.

Turning layers on

One environment variable controls which layers exist on an instance. The default is structural; cascade and semantic both imply structural. All config is Zod-validated at boot — an unknown token rejects boot naming the offender, never a silent skip. Workload routing has no token of its own: the structural classes ride structural and the semantic classes ride semantic.

.env
# default is "structural"; cascade and semantic both imply structural
ROUTING_AUTO_LAYERS=structural,cascade,semantic

Layer 1 · structural

Structural classification is the cheap, language-neutral first pass — sub-millisecond, zero cost. It runs only for model:"auto" requests that reached the default tier, and scores a handful of local features to judge how heavy the task is. The same feature vector yields a second, independent verdict — the request's workload class (Workload routing) — computed once and recorded alongside the band.

The features it reads

Seven weighted sub-scores, each saturating at its own ceiling and combined into one score in [0, 1]. The default weights sum to 1 and are overridable with ROUTING_STRUCTURAL_WEIGHTS. Size is deliberately capped at 0.30 so a long prompt alone can never reach the top tier — high needs several signals at once.

Input size (characters, baseline-subtracted)0.30
Fenced code-block volume0.20
Tool / function definition count0.20
Response-format / schema requirements0.10
Conversation depth (message count)0.10
Multimodal / vision content presence0.05
Requested max output tokens0.05
Override Declared reasoning effort works twice — it shifts the score by a centred adjustment (the reasoning key of ROUTING_STRUCTURAL_WEIGHTS, default 0.10, and unlike the ambient features it is not normalized), and a maximal declaration is a band rule on top of that score. A low declaration therefore pushes the score down as well. A maximal declaration (high/xhigh/max effort, or a thinking budget at or above saturation) forces the high band outright, and the reason string says so with declared=max. You asked for the heavy path; L1 does not second-guess it.

Per-agent baselines, subtracted

The system prompt is excluded from scoring outright; separately, each (agent, system-fingerprint) builds an EWMA of its own typical input size, subtracted from the size signal so routine traffic scores on the delta — the score reflects the task, not the boilerplate.

L1 structural · per-agent EMA baseline subtracted
sub-ms · $0.00
agent baseline · ~11,200 chars subtracted from the size signal
task
Note The declaration the override above reads comes from reasoning_effort (OpenAI), thinking budgets (Anthropic), or output_config.effort — whichever your protocol carries. A maximal declaration routes auto_high without further analysis. Undeclared requests classify byte-identically to before.

The three bands

highstructural
Complex request → route auto_high.
lowstructural
Simple request → route auto_low.
ambiguouscontinue
Uncertain → hand to Layer 2, then Layer 3 if still ambiguous.

Telemetry (structural_band / structural_score / structural_band_source / structural_epoch, plus the workload quad workload_class / workload_score / workload_source / workload_revision) is written for every evaluated row — even ambiguous ones that fall through — so nothing routes silently; the reason string is appended to routing_reason. The quad is all-or-nothing and commits atomically with the structural columns: a database constraint rejects a partly-filled workload verdict.

Workload routing

New in v0.15

The bands answer how hard. A workload class answers what kind. Every evaluated auto request records one, and you can act on it two ways: give a whole class its own target, or give a class its own strong/cheap band pair. Detection is never keyword-based — the structural classes fall out of the features Layer 1 already computed, the reserved classes out of the Layer-2 embedding. Unset classes change nothing.

The taxonomy

ClassSourceFires when
visionstructuralAn image content block is present in the scored window.
structuredstructuralThe request declares a JSON output format (OpenAI response_format or Anthropic output_config.format).
codestructuralFenced-code chars are at least codeShare of the counted text and at least codeMinChars absolute — 0.30 and 200 by default.
researchsemanticThe embedding's nearest class anchor, when it beats the runner-up by SEMANTIC_WORKLOAD_MARGIN (0.05) and clears SEMANTIC_WORKLOAD_MIN_SIM (0.20).
writingsemanticSame rails as research; the semantic source never emits a structural class.
noneeitherNothing fired. Recorded for telemetry; it never claims and never scopes a band.

When several structural signals fire, one class wins, in this order: vision first, then structured, then code. The structural source keeps precedence over the semantic one, which is consulted only when structural came back none. The taxonomy is fixed — a new class is a versioned change, never configuration.

Two ways to act on a class

Claim the class
An auto_workload rule bound to the class points at a tier or a model. It claims the request before band targets, Layer 2 and the cascade, and records decision_layer=workload. An explicit model or the tier header still wins. No rule, or an unresolvable/empty target, means unclaimed — never an error.
Scope the bands
An auto_high/auto_low rule can carry a class, giving that class its own strong/cheap pair. If a scoped rule exists for the band it decides — resolved, it routes; unresolvable, the band is unroutable for that class, never a silent fall-back to the generic rule.
Only where no scoped rule exists does the generic row apply. Cascade stays inside the scope.

What gets recorded

Both sources emit a numbers-and-class-names-only reason; the deciding one rides the same atomic commit as the structural verdict.

workload:code score=0.42 share=0.42 codechars=1180 mm=0 rf=0
workload:research score=0.4871 m=0.0912 sim2=0.3959 top=research top2=writing src=semantic

A scoped decision appends scope=<class> as the last fragment of routing_reason — after the quality marker, the fall-back trail and the classification trail — so it is always a reliable suffix to test for.

.env · optional
# known keys merge over the defaults; an unknown key or an out-of-range value rejects boot
ROUTING_WORKLOAD_THRESHOLDS={"codeShare":0.3,"codeMinChars":200}
Note Both controls live on the Routing page's Auto section — Workload targets for the claims, the per-workload bands block of Band targets for the scoped pairs. The research and writing rows are reserved until the semantic workload source is effective, and say which half is missing until then.

Layer 2 · semantic

New in v0.8

When Layer 1 lands on ambiguous, Layer 2 looks at what the request actually says. It embeds the request text with a small local model and classifies it against curated anchor centroids — turning a "this could go either way" into a confident high or low. It refines only the L1-ambiguous slice: it never re-scores a confident L1 band and never runs on a non-auto request. One thing did widen in v0.15: the embedding itself also feeds the semantic workload source, so a request whose structural workload came back none is embedded even when its band was confident — one vector, used for both, never two embeds.

How it works

01
Extract
Newest user turn first, bounded by char caps. System content is excluded; no evidence → skip.
02
Embed
A local ONNX model on CPU, ~5–20 ms, under a per-call deadline with bounded concurrency. Exactly one embed per request, shared with the workload source. Saturation or a bad vector → skip both; a classifier fault after the embed drops only that verdict.
03
Classify
A three-band cosine score (simHigh - simLow) against anchor centroids. An invalid vector is a fault, never a band.
04
Decide
A confident band routes auto_high/auto_low; ambiguous or any fault degrades to cascade/default.

Band to route

Outcomedecision_layerAction
high band, target resolvessemanticRoute auto_high — never cascades.
low band, target resolvessemanticRoute auto_low — never cascades.
Confident band, target missingdefaultVerdict recorded; falls through to the default tier (mirrors L1 unroutable) — the Layer-0 decision is untouched, but the L2 verdict is still written to the semantic_* columns.
ambiguous banddownstreamHand to Layer 3; the in-memory vector rides to the recorder for learning evidence.
Invalid / fault / unavailablenoneSkip to cascade or default — no verdict, no telemetry.

What the bands mean

high · reasoning-heavy
→ auto_high
Proofs, system designs, debugging, formal analyses — prompts that reward a stronger model.
low · quick
→ auto_low
Small talk, format conversions, lookups, rewrites — cheap and fast is the right call.
Privacy No prompt text or embedding vector is ever logged, persisted to Postgres, put in a metric, or returned by the API. The verdict reason is numbers and taxonomy names only (s= hi= lo= src=, and for a workload verdict top= top2=) — never a word of the request. The first value that can ever reach Redis is a sum over at least SEMANTIC_LEARNING_MIN_COHORT embeddings — never a single raw vector.

Turning Layer 2 on

Default off — the baseline image ships no ONNX runtime and no model. There are two ways to enable it.

A · Batteries-included image
The -semantic variant pre-bakes a checksum-pinned reference MiniLM model and presets SEMANTIC_MODEL_PATH. Bring it up with a compose override.
B · Bring your own
Install the onnxruntime-node@1.27.0 peer, point SEMANTIC_MODEL_PATH at a v1 bundle (model.onnx + vocab.txt + manifest.json), and add semantic to ROUTING_AUTO_LAYERS.
$ docker compose -f docker-compose.yml -f docker-compose.semantic.yml up -d
.env · bring-your-own model
ROUTING_AUTO_LAYERS=structural,cascade,semantic
SEMANTIC_MODEL_PATH=/models/all-MiniLM-L6-v2   # bundle: model.onnx + vocab.txt + manifest.json
Note Once an embedder is loaded, each tenant opts in per-account on the Routing page. The /api/routing/auto-layers endpoint reports semanticAvailable honestly — the whole classifier ready, not just a flag — and splits it into semanticFlagEnabled and semanticClassifierReady so an off switch names which half is missing (add the token, or point SEMANTIC_MODEL_PATH at a bundle / run the -semantic image) — never a dead switch. semanticWorkloadAvailable and semanticWorkload report the same for the workload anchors. Reference model: all-MiniLM-L6-v2 (Apache-2.0, 384-dim).

Layer 2 · learning loop

New in v0.8

Optional and off by default: each tenant can let Layer 2 learn from its own traffic. The cascade's own outcomes weakly label the ambiguous requests it settles, and those labels nudge per-tenant learned centroids — always inside conservative rails, always revertible.

Cascade outcomes become weak labels

Cascade outcomeWeak labelContributes?
Accepted — quality passedlowyes
Escalated by the quality gatehighyes
Escalated by a cheap provider faultno
Cancelled / unknown qualityno

From outcome to centroid

1
Labelled embeddings accumulate in bounded volatile memory, grouped into cohorts.
2
A cohort flushes to Redis only as a sum of at least SEMANTIC_LEARNING_MIN_COHORT embeddings — never a single raw vector.
3
A daily sweep folds fresh evidence under rails — capped EMA, spherical drift clamp toward bundled, cooldown, revision match.
4
It applies crash-atomically — CAS to Postgres (generation bump, scalars-only audit) is authoritative, then the Redis stage is promoted.

The rails

KnobDefaultWhat it bounds
SEMANTIC_LEARNING_MIN_COHORT8Smallest sum that may reach Redis.
SEMANTIC_LEARNING_MIN_SAMPLES50Evidence needed before a fold.
SEMANTIC_LEARNING_ALPHA0.2EMA weight on fresh evidence.
SEMANTIC_LEARNING_MAX_DRIFT0.35Cosine distance the learned centroid may move from bundled.
SEMANTIC_LEARNING_COOLDOWN_H24Minimum hours between applies.
Note Learned centroids supersede bundled only under read-time gates — learning on, and the stored (epoch, generation, revision) matching the decision-time gate. Any Redis fault, stale state, or gate mismatch falls back to bundled, never a skip.

Revert & honest source

One click reverts: it bumps a revocation epoch in Postgres first, which fences every in-flight sweep and stale reader before the Redis keys are even cleared. A learned centroid whose embedder or revision moved under it honestly reports source: bundled — the card never claims "learned" while the router is actually on bundled anchors.

Semantic learning
learning from 12 low · 5 high active: bundled anchors applied 7/3/2026 Revert to bundled
7/3/2026 apply 12 low · 5 high drift 0.03/0.05 · sim 0.97/0.95

Audit rows (apply / discard_revision / revert) carry scalars only — counts, drift, and similarity — never text or vectors.

Layer 3 · cascade

Cascade is the last automatic layer, and it runs only when no workload target claimed the request and both L1 and L2 came back ambiguous (or L2 skipped). Its bet is simple: try the cheap tier first, check the result, and only pay for the strong tier if the cheap answer isn't good enough. When the request carries a workload class with its own band pair, both legs stay inside that class's scope.

1 · cheap auto_low with timeout
Run the cheap tier and buffer the response before any tokens stream to the client.
2 · quality score ↓
score 0.5 or above · accept
Replay the buffered cheap response — done, no escalation.
score below 0.5 · escalate
3 · Strong tier auto_high → fallback → default tier.

The quality gate

The score is a three-valued lattice — 0, 0.5, or 1. A response scores 0 on an error or content_filter stop, empty content, malformed tool arguments, or prose where the request demanded machine-parseable output; 0.5 on a length truncation with no hard failure; otherwise 1. Accept at or above the threshold (default 0.5), escalate below it.

Sharper gate Refinements: unparseable structured output escalates; a length-truncated answer grades 0.5; tool-calling turns are exempt from the empty/format checks.
Commit boundary
The cheap response is buffered and graded before streaming begins — the same commit rule as the main proxy, so no model is ever swapped after the first token.
Per-attempt cost ledger
Every attempt in an escalation is recorded to its own ledger (request_attempt), so an escalated request shows what each hop actually cost.

Per-tenant self-calibration

Opt-in and conservative: a daily sweep narrows your ambiguous band inward from your own outcomes, so more requests get a confident L1 answer over time. It's rail-bounded, fully audited, and one click reverts it — and it's degrade-shaped, so a poisoned or stale calibrated pair can never break or stall routing.

Calibration only ever contracts the ambiguous band
low → auto_low
ambiguous
high → auto_high
The edges move inward — never outward — under a drift cap, keeping a minimum gap between the two thresholds.

When a calibrated pair applies

The effective-threshold function is pure and fail-safe. A stored pair is used only when it's complete, finite, ordered, anchored to the current instance defaults (exact-float), and clean under the current rails — contraction direction, drift cap, minimum gap. Anything else reads as the instance defaults.

window 14 days min edge samples 50 step 0.02 max drift 0.1 sweep 0 4 * * *
Note One-click revert (user-wins) snaps the calibrated pair back to the instance defaults. Escalations also record why they happened — quality_gate vs cheap_error — so the narrowing has honest signal to learn from.

Decision trail & inspector

Every routing decision is recorded so you can see exactly why a request went where it did. The request_log.routing_reason column carries the reason of the layer that actually decided. When the smart layers evaluated a request but did not route it — a default fall-through, or a cascade — their verdicts are appended behind a in L1 → L2 order, never overwritten. When the workload stage claims the request, that verdict's reason is the recorded reason. And a decision made by a class-scoped band always ends with scope=<class> — appended last, after the quality marker, the fall-back trail and the classification trail, so the suffix is always testable. The dashboard's Requests inspector renders the whole string.

The ordered routing_reason

Scenariodecision_layerrouting_reason
Explicit modelexplicitexplicit model gpt-5 — a tier name in the model field records explicit tier <key>
Tier headerheaderx-polyrouter-tier: fast
Tier-header remap, or a rule on any other headerheaderheader rule x-polyrouter-tier
Workload claim · structural classworkloadworkload:code score=0.42 share=0.42 codechars=1180 mm=0 rf=0
Workload claim · semantic classworkloadworkload:research score=0.4871 m=0.0912 sim2=0.3959 top=research top2=writing src=semantic
L1 confidentstructuralstructural:high score=0.69 size=0.90 code=0.75 tools=0.50 schema=1.00 depth=0.40 mm=0.00 maxtok=0.60 think=-- rf=0.00
L2 confidentsemanticsemantic:low s=-0.1845 hi=0.3021 lo=0.4866 src=bundled
Class-scoped band decidedstructuralstructural:high score=0.71 … rf=0 — the scoped pair decided; the suffix is always last: scope=code
Cascade escalationcascadecascade: escalated cheap→strong (q=0.31); structural:ambiguous … ; semantic:ambiguous … src=bundled

Two more fragment families ride the same column. Earlier chain failures append ; fell back after: <kind>@<model> — a member never contacted because its provider's circuit was open records skip@<model>, deliberately outside the error taxonomy. The output-cap guardrails append output_cap_clamped <ask>→<cap> (<model>) when a requested max-output is clamped to the model's known cap, and output_cap_deferred <model>(<cap><<ask>) when a chain member is passed over because its cap is smaller than the ask. One literal note on the cascade row above: cascade: escalated cheap→<tier> names the tier that actually served — the strong tier normally, the default tier when the reliable core rescued the request.

Which header chose the route

When a header decides, the inspector shows which one — but only what's safe to store. The built-in tier header records the owned config value that matched — the tier key on a direct lookup, or the remap rule's own header value (e.g. shopping) when a tier-header remap matched; never raw client bytes. A rule on any other header records the name only, because a configured rule value can itself be a credential. Fail-closed by design.

Requestheader namevalue
x-polyrouter-tier: fastx-polyrouter-tierfast (owned tier key)
x-polyrouter-tier: shoppingx-polyrouter-tiershopping (rule value; target tier heavy)
x-env: prod → tier fastx-envnull (never recorded)
explicit model / defaultnullnull

Inside the inspector

localhost:3001
gpt-5-mini
a3f21c8e-… · 2026-07-21 14:02:31
Fallback · served
my-agent router · semantic openrouter · cheap
Decision
decision layersemantic
routing reasonsemantic:low s=-0.1845 hi=0.3021 lo=0.4866 src=bundled; fell back after: rate_limit@claude-haiku-4.5, skip@llama-3.3-70b
semantic sourcebundled · low
workloadworkload · code (structural)
Fallback trail
claude-haiku-4.5rate_limit · HTTP 429
llama-3.3-70bskipped — circuit open (provider not contacted)
Usage & cost
input tokens1,204
output tokens380
cache read tokens960
input price$0.25 / 1M
output price$2 / 1M
cache read price$0.03 / 1M
cache write priceunpriced
price sourcebundled
served cost$0.0021
attempt cost$0.0000
total$0.0021
Token counts from provider usage; unit prices snapshotted at request time.
Timing
duration0.69s
statussuccess
The request inspector for a single fallback-served request: the decision layer and its routing reason, the fallback trail naming each earlier attempt and why it was passed over, the per-token usage and cost with its price source, and the timing.

Metadata only by default — tokens, cost, routing decision, latency. Prompt and response bodies are never stored unless you turn on encrypted body capture (self-host only, off by default), in which case the drawer grows a Payload block; Layer 2's embedding vectors are never persisted at all.

The Decision block also surfaces the matched routing header, an escalated marker, the cascade's quality signal, the semantic source provenance chip and the workload chip — which reads · routed when that class claimed the request — and the aggregated attempt cost covers escalated or fallback requests. Every earlier attempt is listed line by line in the Fallback trail block: the mapped error kind with its HTTP status, or a plain "skipped" note where an open breaker meant the provider was never contacted. See Security & privacy for the full data model.

Getting started Providers & models