# Route by caller, time, or budget

Use request metadata, the clock, or live budget state to choose a tier without inspecting the prompt.

Source: https://tokentriage.com/docs/build/routing-by-context

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

Two identical requests may need different models because of who sent them, when they arrived,
or how much budget remains. You can route on that context without inspecting the prompt.

Before you start, configure at least two tiers and a valid routing policy; [Turn routing
on](/docs/build/routing) covers that setup. After copying an example, validate it and explain one
request in `shadow` mode:

```console
$ tokentriage validate --config tokentriage.yaml
$ tokentriage policy explain --req-id <request-id>
```

`shadow` records the choice without changing live traffic. Each example uses an ordered `rules`
list: TokenTriage checks from top to bottom and applies the first match. A **tier** names a model
configuration, such as `cheap` or `frontier`. Checking a rule adds no model calls; a `refuse`
action stops the request before any model is called.

## Choose the context you have

Start with the row that matches your goal. Each section below stands alone.

| Goal | Read this input |
|---|---|
| give a caller, agent, or requested model its own tier | `dim.*`, `trace.*`, or `request.*` |
| move scheduled batch work to a cheaper tier | `ctx.time.*` |
| change behavior as one named budget runs low | `ctx.budget.<id>.*` |
| apply one rule to every caller's budget | `ctx.principal.*` |

## Route by caller, agent, or requested model

Request metadata tells you which customer, workflow step, or model name is involved. Use
`trace.*` for agent activity, `dim.*` for business dimensions such as tenant or plan, and
`request.*` for fields the client sent.

Business dimensions are off by default. Set `ledger.dimensions.enabled: true` before routing
on `dim.*`; `trace.*` and `request.*` do not need that setting.

```yaml
routing:
  default_tier: frontier
  policy:
    version: 1
    root:
      rules:
        - id: vip-tenant-frontier
          when: 'dim.tenant = "acme" or dim.tag.plan = "enterprise"'
          to: { tier: frontier }
        - id: batch-agents-cheap
          when: 'trace.agent in ("summarizer", "labeler") and trace.step_class != "tool_loop"'
          to: { tier: cheap }
        - id: haiku-family-pin
          when: 'request.model ~ "claude-haiku-*"'
          to: { tier: cheap }
      default: { tier: frontier }
```

The VIP rule comes first, so it wins when a request is both enterprise traffic and a batch-agent
step. Move the rules only when you intend to change that priority. `trace.step_class` is
`user_turn`, `tool_loop`, or `provider_state`; the second rule keeps tool loops on the default
tier.

Explain a recorded request and check that the expected rule matched. The `read` lines show the
values used in the decision:

```console
  • rules[batch-agents-cheap]    matched
      read trace.agent = summarizer
      read trace.step_class = user_turn
```

<Details summary="Matching syntax and missing values">

Write membership with parentheses: `in ("a", "b")`. Glob matching with `~` is case-sensitive.
`*` matches a run of characters but does not cross `/`, and `?` matches one non-`/` character.
That means `claude-haiku-*` matches neither `Claude-Haiku-4-5` nor the provider-qualified
`anthropic/claude-haiku-4-5`.

When a client omits a business-dimension header, that `dim.*` value is missing and its comparison
is false. With `ledger.dimensions.enabled: false`, every `dim.*` value is missing regardless of
which headers the client sends. [Routing inputs](/docs/build/routing-inputs) lists every field and
when it may be missing.

</Details>

Use a [quality floor](/docs/calibration/gate-routing-on-quality) instead when the requirement is
"whichever model is good enough" rather than "this caller gets this tier."

## Route by the clock

Batch work that runs overnight can accept a slower, cheaper model that daytime traffic cannot.

```yaml
routing:
  default_tier: frontier
  policy:
    version: 1
    root:
      rules:
        - id: off-peak-batch-cheap
          when: 'trace.agent = "nightly-batch" and (ctx.time.hour_utc >= 22 or ctx.time.hour_utc < 6)'
          to: { tier: cheap }
      default: { tier: frontier }
```

The example matches from 22:00 through 05:59 UTC and only for `nightly-batch`. Keep the agent
condition unless you intend to move every request during that window.

Time inputs are always present. `ctx.time.hour_utc` runs from `0` to `23`,
`ctx.time.weekday` uses `0` for Sunday, and `ctx.time.unix` is the arrival time in epoch seconds.
The main trap is timezone conversion: the policy always compares UTC, not local time.

Explain a request near the edge of the window and check the recorded hour. Use a budget rule
instead when the goal is to control spend rather than schedule work.

<Details summary="Why a replayed decision gets the same answer">

TokenTriage records the request's arrival time and uses that value during evaluation. Replaying
the row under the same policy therefore follows the original time branch, not the branch that
matches the current wall clock.

</Details>

## Degrade, then decline, as a budget runs out

When a budget is close to exhausted, the useful behaviour is usually to keep serving on
something cheaper, and only stop once there is nothing left.

```yaml
routing:
  default_tier: frontier
  policy:
    version: 1
    root:
      rules:
        - id: budget-hard-stop
          when: "ctx.budget.default.exhausted"
          to: { refuse: { status: 429, reason: "budget_exhausted" } }
        - id: budget-degrade
          when: "ctx.budget.default.remaining_frac < 0.20"
          to: { tier: cheap }
      default: { tier: frontier }
```

`ctx.budget.<id>.*` provides three values for a configured budget: `remaining_frac`,
`remaining_usd`, and `exhausted`. The example stops exhausted requests first, then moves
requests to `cheap` after less than 20% remains.

<Callout type="warning">
**Order the hard stop first.** An exhausted budget also has `remaining_frac` at zero, so it
satisfies `< 0.20`. If the degrade rule comes first, it continues to match after exhaustion and
the hard stop never runs. Validation cannot flag this ordering mistake because both conditions
are valid on their own.
</Callout>

Change the budget id, the 20% threshold, or the refusal response to fit your policy. The id must
name a configured budget; an unknown id fails validation.

TokenTriage reads an already-computed budget snapshot, so this rule does not call another
service for each request. If budget state is unavailable or suspended, the values are missing,
the conditions do not match, and the request uses `default` instead of being declined.

A declined request returns your status, an `X-TT-Refused-By-Policy` header, and a body containing
the reason. It also writes `decision.refused_by_policy` to the ledger:

```json
{
  "error": {
    "type": "policy_refused",
    "message": "request refused by routing policy: budget_exhausted",
    "refused_by_policy": "budget_exhausted"
  }
}
```

This policy declines requests only in `route` mode. In `ledger` and `shadow`, TokenTriage records
the plan and still serves the request. Use [governance](/docs/build/governance) when a spending
limit must be enforced in every mode.

## Route by the caller's own budget

The previous section names one budget. This rule reads all budgets that apply to the current
caller, so the same condition works for every virtual key.

```yaml
routing:
  default_tier: frontier
  policy:
    version: 1
    root:
      rules:
        - id: budget-aware
          when: "ctx.principal.budget_remaining_frac < 0.10"
          to: { tier: cheap }
      default: { tier: frontier }
```

`ctx.principal.budget_remaining_frac` is the smallest remaining fraction across the caller's
key, user, team, organisation, and global budgets. A key that has spent 95% of its budget reads
`0.05` and routes to `cheap`. This input requires `governance.enabled: true`.

Anonymous or unresolved traffic has no `ctx.principal.*` values, so the condition does not match
and the request uses `default`.

<Details summary="Do not route on remaining request-rate capacity yet">

`ctx.principal.rpm_remaining_frac` exists in the policy language, but this build always reports
it as fully available. A low-remaining condition on that field does not fire. Use
`budget_remaining_frac` for routing, or use [governance](/docs/build/governance) to enforce
request-rate limits.

</Details>

Reading `ctx.principal.*` steers the request in `route` mode. Use governance when the request
must be admitted or declined regardless of the routing mode.

## Avoid negating a value that may be missing

Most comparisons are false when a context value is missing, but `not` can turn a missing value
into `true`. For example, `not ctx.budget.default.exhausted` matches when budget state is
unavailable. Prefer a positive numeric condition such as
`ctx.budget.default.remaining_frac > 0.10`, which is false when the value is missing.

[How a missing input behaves](/docs/build/routing-decision#how-a-missing-input-behaves) has the
complete truth table.

<NextSteps items={[
  { title: 'Route on what the prompt looks like', href: '/docs/build/routing-by-prompt', description: 'The same node kind, reading complexity, topic similarity and expected cost instead.' },
  { title: 'Governance', href: '/docs/build/governance', description: 'Where a spending or rate limit is actually enforced, in every mode, whatever routing decides.' },
  { title: 'Routing inputs', href: '/docs/build/routing-inputs', description: 'Every trace, dimension, clock and governance input, with the exact conditions each is absent under.' },
  { title: 'Cap spend per virtual key', href: '/docs/recipes/cap-spend-per-virtual-key', description: 'The recipe for a hard cap, as opposed to the soft steer this page builds.' }
]} />
