Model routing
Six upstreams behind one set of policies, and a fallback chain that will not launder a refusal.
On this page
A fallback chain is a governance surface, not just an availability one
If you have configured a fallback chain, you have already built the mechanism that can quietly defeat your own controls. One vendor’s safety system declines a request; the chain sends the same payload to the next vendor; the second one answers; the trace records a success and the objection is nowhere in the evidence. Nothing was bypassed on purpose. The retry count simply did not know the difference between a provider being down and a provider saying no.
The same shape of error costs money in three other ways. A bad credential fails identically everywhere that credential is used, so failing over hides a broken key behind a more expensive provider until the invoice arrives. An over-long context is a property of the payload, not the provider, and fallbacks usually have similar or smaller windows, so failing over pays a full input-token charge to receive the same error twice. A malformed request is malformed at every vendor.
The second problem is equivalence, and it is the one that decides whether any of your policies mean anything. An estate that adds Gemini or Bedrock beside OpenAI now depends on the claim that a rule written once binds all of them. That claim is easy to make and easy to be wrong about: a policy that fires on OpenAI but not on Gemini is worse than no policy, because the operator believes they are covered. Token Observe answers it by asserting every guarantee once per provider kind rather than once for the default upstream, and by asserting it at the provider — what the upstream was actually handed — rather than at the response the client got back.
The third is that enterprise procurement does not ask one data-handling question. It asks three, and they are independent in practice: is there a zero-data-retention agreement, are our payloads excluded from training, and where is this processed. A single retention flag makes the operator silently decide which of the three it means and then be wrong about the other two, and it conflates a residency constraint — a legal and geographic property — with a retention one, so an EU-pinned agent and a zero-retention agent become indistinguishable to the router.
How it actually works
Step 8 of the request path. The order is load-bearing rather than incidental: it is encoded in the evaluator, and the reason each step sits where it does is the reason the guarantee holds.
- 01
Match a route rule
Enabled rules are sorted by priority, lowest number first, and the first whose model pattern matches the requested name wins. Patterns are literal apart from an asterisk, which matches any run of characters, and matching is case-insensitive. A rule names one target provider and model plus an ordered list of fallbacks, so a client that only knows how to say gpt-4 can be pointed anywhere without the client changing. - 02
Filter every candidate through the agent’s data policy
The same predicate is applied to the primary target and to each fallback: a provider must be enabled, and must satisfy every constraint the agent’s data policy states. A primary that fails the policy is skipped and the first compliant fallback is promoted with the rest kept behind it; if nothing in that rule is usable, resolution falls through to the next matching rule. - 03
Fall back to pass-through when no rule matches
Enabled, policy-satisfying providers are sorted by priority and the first with an exact, non-wildcard price row for the requested model is chosen, because an exact row is the evidence that this provider serves that model natively. Failing that, one whose pattern row prices it; failing that, the lowest priority number. A pass-through decision offers no fallbacks, which is honest: nothing has been configured about where this model should go if that provider fails. - 04
Drop targets that cannot preserve the caller’s vendor-specific fields
Unknown top-level request fields are governed and then forwarded, but only to a provider family that understands them: OpenAI-shaped extras to OpenAI, OpenRouter and Azure OpenAI rows, Anthropic-shaped ones to Anthropic and Bedrock, Gemini-shaped ones to Google. A configured failover that would drop or reinterpret them is removed and a compatible fallback promoted; when no compatible target remains the request is refused before provider egress rather than silently reinterpreted. - 05
Price the whole chain before anything leaves
An agent with any USD ceiling is refused before egress when the resolved target, or any fallback still standing behind it, has no active price row. The check uses the resolved model and provider kind rather than the name the caller supplied, so an alias, a reroute or a failover cannot exchange a priced request for an unpriced one. Agents with no USD ceiling are explicitly unbudgeted and are not subject to this. - 06
Call, retry inside the provider, then fail over by class
A transient failure is retried at most twice beyond the first attempt, with full-jitter backoff capped at four seconds and an upstream Retry-After honoured up to ten. Only transient classes count against that provider’s circuit breaker. When the provider is exhausted, the failure class — not a counter — decides whether the next candidate is tried at all. - 07
Narrow the route to the provider that served
Once a call succeeds, the route is rebuilt around the provider that actually answered, and that narrowed route is what metering prices against. This was a real defect: the gateway used to hand the original route to metering, so a failed-over request was priced at the first provider’s rates and its spend attributed to a provider that never ran it. Cost was wrong whenever the fallback priced differently and per-provider attribution was wrong every time.
Typed failover: the class of the failure decides, not a counter
Failover is decided by what went wrong, not by how many times it has gone wrong. Upstream failures are classified into a closed union of seven classes, and exactly three of them — timeout, rate-limited and server error — move the request to the next provider in the chain. Content-policy refusal, authentication failure, over-long context and invalid request all stop where they are. Three of those fail identically at every vendor, so trying the next one only pays a second vendor to return the same error. The fourth is the deliberate one: a content-policy refusal might well have succeeded elsewhere, and that is precisely why it is not tried — a second attempt at a declined action, recorded on the trace as a success, is the opposite of evidence.
The alternative was considered and rejected on the record. Retrying every failure down the chain maximises apparent availability and is what a pure availability product should do; three failure modes make it unacceptable for a governance product. A content-policy refusal is a governance signal, and a second attempt at the same action recorded as a success is the opposite of evidence. An authentication failure means the vendor key is wrong, revoked or rate-scoped, and failing over masks a broken key behind a more expensive provider. An over-long context is a property of the payload, and fallbacks usually have similar or smaller windows.
The switch that makes the decision is exhaustive with no default branch, so adding an eighth class is a compile error until someone has decided its failover behaviour. That is the whole enforcement mechanism, and it is deliberately a language-level one rather than a review convention.
The limits are stated in the same register. Classification is a lossy mapping from heterogeneous provider error shapes onto seven classes and it will get cases wrong: a content refusal returned as a bare HTTP 400 is indistinguishable from a malformed request, and while both correctly decline to fail over, the reason recorded on the trace will be the wrong one. A provider that returns a 5xx for what is really a refusal will be failed over, producing exactly the laundering this design exists to prevent — the control depends on upstream error hygiene Token Observe does not control. And availability is genuinely traded away here: requests that a dumber gateway would have completed on a second provider will fail.
- Status first, message second
- 408 is a timeout, 429 is rate-limited, anything 5xx is a server error, and 401 or 403 is an authentication failure — all decided on the status alone. The body is read on every failed response, under a size bound, but it only decides anything for the statuses those rules leave over: a context-length complaint is recognised on a 400, a content-policy one on any status that got this far, and whatever remains is an invalid request.
- A refusal that arrives as a 200
- Gemini returns a blocked prompt as a successful HTTP response carrying a block reason and no candidates. That is mapped to content-policy so the router does not replay a refused prompt at the next vendor — one of five dialect differences behind the decision to write a first-class Gemini client rather than run the vendor through an OpenAI-compatible approximation.
- Bedrock speaks in exception names
- Throttling becomes rate-limited, model timeout becomes timeout, validation becomes invalid request, access denied becomes an authentication failure, and anything unrecognised becomes a server error — which is the safe reading of we do not know, since server errors are the class that may fail over.
- Provider error text never crosses the boundary
- A failed response body is read for classification only, under a size bound, and the message that reaches your logs, traces and API responses is generated by Token Observe. A proxy or a hostile endpoint can echo a prompt, an Authorization header or a credential-bearing URL in any error field it likes.
timeout → fail over to the next provider
rate_limited → fail over to the next provider
server_error → fail over to the next provider
context_too_long → stop (a property of the payload, not the vendor)
content_policy → stop (a refusal is a governance signal, not an outage)
auth → stop (the same credential fails the same way everywhere)
invalid_request → stop (malformed is malformed at every vendor)Three independent data-policy constraints, enforced fail-closed
Each agent carries three data-handling requirements rather than one flag: zero data retention, exclusion from training, and a serving region. Every constraint the agent states must hold against the provider row before that provider may serve the agent, and the identical predicate filters the primary target and every fallback, so a data policy cannot be bypassed by failing over. When no enabled provider satisfies the policy, routing refuses with a 403 rather than downgrading, because for a governance product silently routing an EU-pinned agent to a US endpoint is the worst available outcome.
Three fields exist because the three questions are genuinely independent in the market. A provider may retain payloads for thirty days of abuse monitoring while being contractually barred from training on them; another may retain nothing and serve only from US regions. The richer alternative — a per-provider processing agreement record, a sub-processor list, a retention period in days, a transfer mechanism, and a policy expression language over all of it — is the correct long-run model and was rejected as speculative today, on the grounds that nobody is asking for retention arithmetic, that processing agreements are prose rather than integers, and that the modelling cost becomes irreversible the moment it enters the API contract.
The honest weakness is the one that matters most to a CISO: the provider-side fields are operator-asserted, not verified. Nothing checks that a row marked zero-data-retention corresponds to a signed agreement. There is no attestation, no expiry, and no link to the underlying contract, so the control is exactly as good as the person who filled in the form. That is carried in the product’s own threat model as a residual risk requiring dated acceptance rather than being presented as a solved problem.
Two smaller limits travel with it. Region matching is case-insensitive string equality with no hierarchy, so an agent requiring eu will not match a provider tagged eu-west-1, and there is no way to express EU or UK. And a single refusal code covers all three failure kinds, including a pure region mismatch where the code’s name reads oddly; it was kept because the error-code set is a published contract and changing it would break callers, and the message names the actual cause.
- Zero data retention
- Matched against a per-provider flag stating that a zero-retention agreement is in place for that upstream. Stated on the agent, so two agents in the same deployment can hold different requirements against the same registry.
- No training
- A separate contractual question from retention, and deliberately not folded into it. A provider can be barred from training on your payloads while still retaining them for a period of abuse monitoring, and an operator who had one flag would have to pick which of those they meant.
- Region
- Case-insensitive exact match against the provider’s declared serving region. Exactness is the point for a residency control: a prefix or fuzzy match would let eu quietly admit a provider tagged something that merely begins the same way.
- Bedrock residency is bound to its endpoint
- A Bedrock row that declares a region is refused unless its endpoint hostname is an AWS-owned Bedrock runtime name carrying a verifiable regional mapping, and refused again if the declared region, the endpoint region or the SigV4 signing region disagree. Putting a region string into an operator-controlled hostname is not evidence of where the bytes go, and signing for one region while sending prompts to another is a configuration failure rather than an override.
agent.dataPolicy requireZdr: true
requireNoTraining: true
requireRegion: "eu-west-2"
provider row zdr: true
noTraining: true
region: "eu-west-2" ← bound to the endpoint
for Bedrock rows
no provider satisfies the policy → 403, never a downgradeThe equivalence claim is a test, not a sentence in a brochure
Every governance guarantee is asserted once per provider kind, not once for the default upstream and taken on trust for the rest. A table-driven suite runs six guarantees against each of the six governed kinds: a model the agent has no grant for is refused and the provider never sees it; a card number in the prompt reaches the upstream masked; an agent over its daily cap is refused before egress; a kill switch silences the upstream rather than telling the client a story; the trace carries that provider’s name, its own price row and a cost that reconciles arithmetically; and a request gated on human approval blocks, then executes exactly once the approval is granted.
Two properties are what make those assertions mean what they say. Blocked is asserted at the provider rather than at the response — each fake upstream records what it was asked to do, so an empty call list is evidence that the payload never left, where a 403 alone would not distinguish a refusal from a call that happened and was then hidden. And every refusal test carries a positive control, because without one, the provider was never called would also pass if routing never reached that provider at all, which would make the entire suite green and worthless.
A second suite proves the stronger statement an operator actually relies on: one policy, written once, binds every upstream in the same deployment. Six providers are registered in one process, a single redaction policy is created, and the raw card number appears in nothing any of them received; then one kill switch silences all six. A third suite checks the real provider registry can build a live client for every governed kind from a configured, enabled, credentialed row — because the governance suites inject their fakes straight into the registry, and that shortcut would otherwise happily hide a provider Token Observe cannot actually reach.
The guard against the table going stale is that it is a record keyed on the provider-kind type, so adding a kind stops the file compiling until its author has said how the same six guarantees are met. That guard failed silently once, and the confession is the first thing in the test file: for two releases the typecheck configuration included only the source directory, so nothing ever read the test file, and the table sat at four of the six governed kinds. Bedrock and Azure OpenAI were reachable through the gateway and asserted nowhere, while the documentation claimed equivalence across every kind. A compile-time guard that nothing compiles is a comment.
- Model permissions
- An agent without an invoke grant for the model is refused, the upstream call list is empty, and no other provider in the registry received the payload by way of a fallback either.
- Redaction at egress
- What the provider was handed is inspected, not what the client got back. The card number is absent, the placeholder is present, and the rest of the prompt survives intact so the model can still do the work.
- Budget and kill switch
- Both refuse before the provider is called, and the kill-switch test runs its positive control first so the difference between silenced and never wired is visible.
- Cost attribution
- The trace event names the provider, and the recorded cost is reconciled against that kind’s own price row rather than a shared fixture rate, so the arithmetic only balances if the ledger keyed off the provider that actually served.
- Human approval
- The gate blocks with an approval id and an awaiting-approval trace, and then opens: an approval that never lets the call through would be indistinguishable from a block.
- The mock is excluded, and cannot be reached beside a real upstream
- A seventh kind exists for offline demonstration, and it is skipped entirely whenever any real provider row is enabled — because nothing downstream can distinguish its invented text from a real completion, which would make fabricated output served to production a routing accident.
Circuit breakers, retry discipline, and who gets billed
Each provider has its own circuit breaker, held by the provider registry, and it opens after five consecutive transient failures and admits a probe thirty seconds later. Only transient classes count against it: a 400 from a malformed request says nothing about the provider’s health, and letting it trip the breaker would take a healthy upstream out of rotation because of one bad caller. Those two numbers are exported constants rather than literals scattered through the code, because anything that reasons about when a provider drops out of rotation — the request path, an alert threshold, a test — has to read the same figure.
Breakers deliberately outlive configuration reloads. They are keyed by provider id in a map that a registry refresh never touches, because rebuilding a breaker on config reload would hand a flapping upstream a clean slate on every edit, which is precisely how a breaker stops working. There is also exactly one breaker instance per provider: the metrics gauge reads through the same accessor as the request path, so the dashboard reports the state that is actually admitting or refusing traffic rather than a second copy that says healthy right through the outage it exists to reveal.
Inside a provider, a transient failure is retried at most twice beyond the first attempt. The backoff is full jitter — random across the whole window rather than a base plus a random increment — because equal jitter still leaves every client retrying inside the same narrow band, which is how a recovering provider gets knocked over a second time. The window is capped at four seconds and an upstream Retry-After is honoured up to ten, which is respect rather than obedience: a vendor asking for a five-minute wait must not hang the request.
One exception is worth knowing before you enable USD budgets. An agent with any USD ceiling gets exactly one network attempt for the whole chain: no retry, no failover. A timeout is ambiguous — the vendor may have completed and billed the call — so retrying or failing over would let a single admission reservation cover several independently billable attempts, and a budget that can be exceeded by the retry policy is not a budget. Agents with no USD ceiling keep the full retry and failover behaviour.
Registry behaviour is built on the same principle that an absent provider should degrade rather than crash. A provider whose credential environment variable is unset is logged by name and left out of the registry, so routing sees it as unavailable and fails over instead of the process refusing to boot; a single malformed row does not take the whole gateway offline, and the operator gets a named reason for the one that did not load. When a deployment has no provider rows registered at all, the refusal says exactly that and what to do about it rather than blaming the model name, because a partner evaluating before they have a vendor key has done nothing wrong and there was nothing in the old message to act on.
- Five failures, thirty seconds
- Tuned to survive a brief provider blip without tripping, while capping how long a genuinely dead upstream keeps absorbing — and charging for — traffic.
- Two retries, full jitter, four-second cap
- Timeouts are included in the retryable set knowing the upstream may already have executed the request; that risk is accepted for a capped two attempts because the alternative is failing calls that a slow first connection would have completed.
- One egress attempt under a USD ceiling
- A hard-budgeted request represents one potentially billable egress. Once it is spent, an ambiguous failure returns to the caller rather than reusing the reservation for another attempt or another route candidate.
- Providers are re-read before every attempt
- Enablement, destination, credential reference and data-handling flags are durable authority and are read again immediately before each egress, including retries, so an edit committed by another replica cannot leave a process dispatching through its boot-time client indefinitely.
- A client disconnect stops the spend
- The caller’s abort signal is folded into the deadline that bounds the whole exchange, response body included, so an abandoned request stops costing money and a retry is not attempted for a response nobody will read.
A routing decision made elsewhere cannot be governed here
Request fields that would move a routing or processing decision outside Token Observe are rejected before egress rather than forwarded. On the OpenRouter dialect that means the caller-controlled models, provider, route, plugins, transforms and web-search options fields: they select vendor-side fallback models, override provider selection and data-policy enforcement, or attach vendor-side processing, search egress and charges that Token Observe cannot authorise or price. The refusal names the field and says what to configure instead — fallback targets and provider constraints belong on a route rule, where the data policy and the price ceiling still reach them.
The same reasoning covers model-name modifiers. A retained target ending in one of the vendor’s routing suffixes enables web-search egress or changes sub-provider selection without making either governable, so those are refused too — but checked after route resolution rather than on the inbound body, so an operator alias that happens to end in the same text and maps to a safe base model still works. Ordinary OpenAI-compatible extras are deliberately not on the list: a seed, a response format or a reasoning-effort setting does not choose a different provider or attach an unpriced processor.
Cheaper-tier substitution is the other place a routing decision could have escaped, and it does not. It is off unless an operator turns it on, and when it is on the only thing it changes is which model name the rest of resolution sees: route rules, provider selection and the data policy all still apply to the substituted model, because a cost optimisation that skipped them would be a governance hole. If no route exists for the substituted model — a disabled provider, or one this agent’s data policy forbids — the caller is served the model they asked for instead, because a cost optimisation must never take a request down.
The substitution itself is deliberately conservative. It is a heuristic over signals in the request, not a classifier model, because a router that had to call a model to decide which model to call would spend a meaningful share of the saving it exists to produce and would add a second upstream dependency to the request path. It is pure and deterministic, so a decision recorded on a trace can be reproduced from that trace months later. It never routes above the tier the caller asked for, the agent’s tier ceiling applies regardless of everything else, and a downgrade needs both an explicit opt-in and a confident classification — two agreeing signals, not one weak one tipping a borderline score. Spending more of someone’s money than they asked for is not a cost-optimisation feature.
Whatever it decides is written to the trace, including when it decides nothing. An operator who has turned tier routing on needs to see the swap that was not made as much as the one that was, because otherwise the router doing nothing and the router being switched off look identical from the outside.
- Rejected by presence, not by value
- A delegating field is refused even when it is null or empty, so the wire semantics are never silently stripped and then forwarded as though the caller had not asked.
- Checked twice
- Once where a client body becomes canonical, and again immediately before provider compatibility is resolved, so a request built anywhere other than a public wire translator gets the same refusal.
- The saving estimate is biased against itself
- The saving estimate is measured from the cheapest matching price row and clamped at zero, the opposite bias to the ceiling check, because an overstated saving is a number the business would go on to quote.
- The response says what actually served
- A response header reports the model that ran, which is not always the one asked for, and the trace carries the requested model, the routed model, the matched rule, the route reason and the fallbacks that were standing behind it.
What this does not do
Stated here rather than discovered during an evaluation. Every line below closes off a reasonable assumption a reader would otherwise carry into a proof of concept.
- The data-handling flags on a provider are operator-asserted. Nothing checks that a row marked zero-data-retention has a signed agreement behind it, and there is no attestation, no expiry and no link to the underlying contract.
- Region matching is case-insensitive string equality with no hierarchy. An agent requiring eu will not match a provider tagged eu-west-1, and there is no way to express EU or UK.
- There is no mid-stream failover. Failover applies to opening the stream; once bytes have reached the client, retrying would replay a partially delivered message and emit the opening of the answer twice, so the stream ends instead.
- Embeddings are proxied to OpenAI and OpenRouter rows only. Anthropic, Google, Bedrock and Azure OpenAI targets fail closed before the credential is even resolved rather than being sent a request in a dialect they do not speak.
- Routing is not one-directional in general. A route rule an operator writes can point a requested model at a target that costs more than the one asked for, and no check compares the two prices — the trace records the requested and the routed name either way. Only the automatic tier optimiser goes strictly downward, and only where an operator has enabled it, the agent permits a downgrade and the classification is confident.
If one of those limits is the thing that decides it for you, say so and you will get a straight answer about whether it is on the roadmap or out of scope.
Talk it throughWhat this leans on
Policy engine
One deterministic verdict on every governed request: allow, block, redact, or park it for a human.
Spend controls
Hard USD ceilings, per-minute rate limits and a kill switch, all decided before the request leaves your network.
Flight recorder
Every governed request in a timeline a compliance officer can read, and a search box that never writes SQL.
What happens when one provider refuses a request on content-policy grounds?
It stops there. A content-policy refusal is classified as such and excluded from failover, because sending the same payload to the next vendor is a second attempt at the same action and the trace would then record a success with the objection hidden. The same applies to an authentication failure, a malformed request and an over-long context: all three fail identically at every provider, so failing over would only pay a second vendor to return the same error. The limit worth stating beside that claim is that the control depends on upstream error hygiene — a provider that returns a 5xx for what is really a refusal will be failed over.
Can an agent be pinned to providers in one region?
Yes. An agent’s data policy carries three independent requirements — zero data retention, exclusion from training, and a serving region — and every constraint it states must hold before a provider can serve that agent. The same predicate filters the primary target and every fallback, so failing over cannot bypass it, and when nothing satisfies the policy the request is refused with a 403 rather than downgraded. Two limits travel with that: the provider-side flags are operator-asserted with no attestation or expiry behind them, and region matching is case-insensitive string equality with no hierarchy, so an agent requiring eu will not match a provider tagged eu-west-1.
Can Token Observe route to a model endpoint of my own?
Yes. An OpenAI-compatible endpoint of your own — a self-hosted server, an internal gateway, a VPC endpoint — is registered as an OpenAI-kind provider row with its own base URL, and it is then governed exactly like a vendor upstream. The destination is allowlisted rather than merely configurable: a provider row names both a URL and the environment variable holding its credential, so an unconstrained registry write would amount to reading every secret in the one process that concentrates every provider key in your estate. The host allowlist defaults to the shipped vendor hosts, so an internal endpoint must be named explicitly, and persisted rows are rechecked against it on load rather than grandfathered.
How do you know a policy behaves the same on Gemini as on OpenAI?
Because it is asserted per provider kind rather than assumed. A table-driven suite runs six guarantees — model permissions, redaction, budget, kill switch, trace attribution and human approval — once for each governed kind, and every assertion is made at the provider rather than at the response, so an empty upstream call list is evidence the payload never left. Each refusal test carries a positive control, and a separate suite proves the real registry can build a live client for every kind. That guard once failed quietly: the test directory sat outside the typechecked project, so the table covered four of six kinds for two releases.
What happens when the primary provider is down?
Within a provider, a transient failure is retried at most twice beyond the first attempt, with full-jitter backoff capped at four seconds and an upstream Retry-After honoured up to ten. If it still fails, the call moves to the next candidate in the chain. Five consecutive transient failures open that provider’s circuit breaker, which then skips it for thirty seconds before admitting a probe. When the chain is exhausted the caller gets a typed error carrying the code for that failure class — a timeout, a vendor rate limit and a refused credential are deliberately not one message — and it names how many providers were tried whenever more than one was. One exception: a request under a USD ceiling gets exactly one network attempt, because a timeout may already have been billed.
Does routing ever change the model my agent asked for?
It can, in two ways, and both are visible. A route rule may name a different target model for a matched request, which is what makes one client-side model name portable across vendors; and optional tier substitution may serve a cheaper model where an operator has enabled it, the agent permits downgrades and the classification is confident. Substitution never goes upward, and the agent’s tier ceiling applies regardless of everything else. The response header reports the model actually served, and the trace records the requested model, the routed model, the rule that matched, the route reason and the fallbacks that were standing by.
Prefer to ask a person? Write to us →
Bring us the agent you are least comfortable with.
Write to hello@tenhaw.com with what your agents do, which providers they call and what would have to be true for you to put something in front of them. James Rooney replies. You will get a straight answer about whether Token Observe fits, including when it does not.
no form · no qualification step · no sales desk · the other three ways in