Choose a routing strategy for each model group. Strategies select among eligible candidates; authentication, model permissions, budgets, health and fallback checks still apply. Weighted-random is the default. Adaptive and quality-based paths remain experimental and opt-in; see release scope.
Where a strategy applies#
A group is a model name your callers ask for that stands in front of several real
candidates; the group's strategy picks one per request. A request naming a real model is
not a group and none of this applies - groups are purely additive. Defining one:
send some of your traffic to a different model.
Two things happen before any strategy runs, whichever you chose:
- Health filtering. A candidate whose provider breaker is open, whose deployment is on a 429 cooldown, or which fails a capability or context-window check is dropped from the set the strategy sees. No strategy can pick a candidate the filter removed.
- Vocabulary checking. An unknown strategy name refuses at boot and names the legal set - A custom strategy you register as a Spring bean is legal even though it is not in the shipped list; the vocabulary closes over registered beans, not over this page.
The menu#
Use the selection rule and tradeoff together when choosing a strategy.
| Strategy | Selects by | Useful for | Main limitation |
|---|---|---|---|
priority |
First eligible candidate | Predictable primary/fallback order | Healthy primary takes all traffic; no spreading |
random / weighted-random |
Random choice, optionally weighted | Canaries and A/B splits | Weights do not adapt to price or model changes |
round-robin |
Even rotation with a counter per group | Equal request distribution | Unequal candidates receive equal traffic |
latency |
Peak EWMA × (1 + in-flight requests) | Avoiding slow or overloaded candidates | Speed is not answer quality |
cost |
Lowest declared blended input/output price per 1k tokens | Static cost preference | Sees neither latency nor quality |
least-busy |
Fewest live in-flight requests; priority breaks ties | Shifting work from occupied deployments | Busyness does not measure suitability |
usage |
Most outbound TPM headroom | Spreading traffic across provider quotas | Capacity is not quality or price |
bandit |
Observed outcomes under a cost or balanced objective | Experimental adaptive selection | Depends on evidence and cold-start behavior |
lints |
Linear Thompson Sampling conditioned on request features | Experimental contextual selection | Requires the learner and meaningful feedback |
jev |
External task classification plus operator rankings | Experimental task-specific routing | Exports content and adds operator-paid classification |
quality-guarded |
Task-qualified models with valid evidence | Experimental evidence-constrained selection | Qualification covers the recorded rubric and distribution |
Static selection details#
The latency strategy probes unmeasured candidates first and randomizes within a buffer of the best score. This avoids permanently starving unmeasured candidates or concentrating every request on a single fast provider.
The static cost strategy treats declared zero as free, but excludes undeclared prices and increments pointsman.routing.unpriced_excluded. If every candidate is unpriced, it reports the condition and uses priority order.
Adaptive objectives (bandit)#
The bandit strategy uses Thompson sampling over observed outcomes. Enable pointsman.bandit.enabled; without the flag, the strategy name remains legal but the group falls back to the default strategy.
- Balanced objective: uses the learner's blended reward. Cold start begins with equal priors; without a quality signal it optimizes efficiency rather than correctness.
- Cost objective: learns observed cost per completed call in the gateway. It does not require or send data to the learner.
Read the learning model for the objectives and the setup guide to enable feedback.
Choose an objective#
A bandit group optimises one of two objectives, chosen per group:
yaml
pointsman:
routing:
groups:
support:
strategy: bandit
objective: cost # or balanced (the default; omit the line for it)
candidates: [ ... ]
balanced(default): the behaviour described above. It is Thompson sampling on the learner's blended reward (success, latency, answer length, cost relative to the other candidates, and ratings when you send them).cost: Thompson sampling on each member's observed cost per completed call. That is the registry's cache-aware price applied to the usage the provider reported, started from the declared prices. On day one it ranks as the prices do. When a model's real cost per request drifts from its price (it writes or reasons more, a prompt-cache discount), it can adapt as observations accumulate. It runs in-process and sends nothing to the learner. The group still needspointsman.bandit.enabled, because that flag creates the bandit strategy.
Different from the static cost strategy: that one always takes the lowest declared price and never
looks at a bill.
State and persistence#
balanced learns in the policy service (the learner), which publishes
posteriors the gateway polls; it survives a gateway restart. cost learns in the gateway's memory, per tenant and
group: a restart starts again from declared prices, and the state is bounded (at most 10,000 tenant-group states, the
least recently used dropped beyond that, which only returns that group to declared prices; at most 64 members per
state; pointsman.cost-sampler.*). The cost-state read and the Observe > Belief page show it.
Deployment identity#
Both objectives learn per provider and model (provider:model): the same model behind
two providers is two members with their own cost and reliability, and a failure at one never lowers the other.
Free and unpriced models#
For objective: cost, A model whose declared rates are both zero is free: it scores zero
and wins on cost; its successes and failures are still learned, and among free members the more reliable wins. A
model with no declared price is never treated as free: it is chosen last. Neither has a cost multiplier to learn. A
free model that fails is still subject to the health filter, the breaker, cooldown and the fallback ladder.
Members without observations#
A member the sampler has not observed yet - a second deployment, or a member added later - is judged at its declared price times what the group's observed members actually cost against theirs, so it is tried and not starved by siblings whose bills came in under their estimates. With nothing observed in the group, the declared prices decide, as on day one.
Streaming usage#
A row reports usage on streams only when it declares the streamUsage: stream_options quirk. Without it a
completed stream still counts as completed, but it teaches no cost; use the quirk where the provider supports it.
Quality boundary#
objective: cost does not judge answers. It optimises cost per completed call, so it will happily buy
cheap wrong answers if the group contains models that are not good enough for the work. Put only models you trust
for that work in such a group, or use quality-guarded routing with selection: cost, which restricts the same
sampler to models qualified on your own labelled data.
An unknown objective, or objective on a group whose strategy is not bandit, refuses startup.
Supported parameters#
The objective is the one choice a group makes. Every other bandit and policy-service parameter ships at its default and is not a supported tuning surface in v1. A dashboard playground for tuning, with a what-if replay before a change takes effect, is planned after v1.0 (roadmap).
Contextual selection (lints)#
Linear Thompson Sampling, the contextual variant: the bandit above, but the posterior conditions on request features. Same flag, same learner.
Task classification (jev)#
Jev classifies the task, then applies the operator's ranking within the already eligible shortlist. See setup and evaluation.
| Aspect | Behavior |
|---|---|
| Classifier | TypeSafe Jev, pinned jev-1.13.0; labels code, summarize, extract, reasoning, knowledge or chat |
| Default | Disabled; each tenant must opt in |
| Data and cost | Accepted conversation text, including system/developer messages, goes to the classifier. Calls are operator-paid overhead, not tenant charges. |
Valid X-Task-Type hint |
Uses the hint instead of calling the classifier |
| Tools, excessive input, no user turn or uncertain classification | Selects the first eligible candidate, as priority would |
| Provenance | Records the decision reason as jev_task-v1_<reason> |
A task label is a coarse signal, not evidence that the ranked model is better. This strategy does not learn rankings. Evaluate them on your own labeled traffic before relying on them.
Evidence-constrained selection (quality-guarded)#
Quality-guarded routing is experimental and disabled by default (pointsman.quality.enabled). It selects among models qualified for the request's task profile. Qualification requires a registered, adjudicated evaluation epoch with valid intervals, absolute and baseline-relative requirements, a severe-error ceiling, unexpired evidence and an unchanged registry row.
| Situation or control | Behavior |
|---|---|
| Qualified models available | Selects the cheapest declared price by default |
| No qualified model | Uses the declared baseline |
| No policy | Refuses 503 quality_policy_unavailable |
| Group selects this strategy while feature is disabled | Refuses startup |
| Session affinity or family preference | Can retain only a permitted model |
| Fallback | Checks every hop; never escalates to a neighboring group |
| Response cache | Bypassed |
| Response evidence | X-Quality-Route identifies the route; signed X-Quality-Receipt must be cited by evaluation evidence |
Qualification covers the recorded task distribution and rubric, not every answer. Without evidence, this strategy offers no cost advantage over the baseline.
The optional bounded cascade can hold a listed group's answer until a deterministic format verifier accepts it. That verifier checks shape, not correctness. See the quality-guarded setup guide for policy and evaluation steps.
What composes on top, regardless of strategy#
Session affinity (session-affinity: true on a group) keeps a multi-turn conversation on
the provider it is already talking to, so the provider's prompt cache stays warm - every
major vendor bills a cached prefix at a fraction of input, and the discount only exists when
consecutive turns land on the same provider. It is a wrapper, not a strategy: it decides
whether your strategy runs at all, so it composes with every entry above. A pin yields only
when its provider leaves the filtered candidate set or after five minutes of inactivity (the
provider cache's own expiry) - and, stated plainly as the trade it is: a pinned conversation
does not move to an arm the bandit now prefers until the pin lapses.
Deprecated deployments are deprioritised at bandit selection (sampled score times a configured penalty, default 0.5), never excluded - un-deprecating reverses it instantly because it is a selection weight, not a reward term the learner would have to unlearn.
The fallback ladder catches the failure after any strategy's pick. The same-deployment
retry comes first, but it is not a rung of the ladder: it is the Resilience4j retry inside one
attempt (pointsman.fallback.same-retries). The ladder proper then walks six rungs, each
re-derived per hop against live breaker and cooldown state - same model elsewhere, your
explicit fallback chain, same family, same tier, one tier up, one tier down. A context-window
overflow leaves that order and follows the window-ascending chain instead: the next candidate
with a strictly larger declared window. Every rung passes the same eligibility gate, so a
fallback pointer changes candidate order and never permissions. Substitutions are disclosed on
the response (X-Attempted-Models, X-Model-Resolution).
When the caller sends no model#
A request that names no model still has to go somewhere, and the blank-model path is its own
small ladder, tried in order: the default model (the key's own, or when the key has none, its
tenant's), then semantic routing,
then the mode tiers, then the cheapest allowed model. Each rung only sees candidates the key is
allowed to use - nothing on this path can escape the allow-set. The unknown-model policy does
not govern this: even the fail-closed resolve-only refuses an unknown name, not an absent one.
When the rung lands on a group, that group's strategy - a bandit with its objective, for example -
chooses within it; when it lands on a single model, no strategy is involved.
Fail safe or fail fast: pointsman.resolution.omitted-model (POINTSMAN_RESOLUTION_OMITTED_MODEL).
route - fail safe (default) |
refuse - fail fast |
|
|---|---|---|
| A request with no model | is answered, by the path above | gets 400 model_required; nothing served, nothing billed |
| Which model | one the gateway chose, named in X-Model-Resolution and X-Resolved-Model |
always exactly the one the client names |
| Cost | billed as the model that served, which may be cheaper or dearer than the client would have chosen | none for the refused request |
| Quality | whatever the path picks; "cheapest allowed" may be weaker than the work needs | not applicable |
| A client bug that drops the model field | silently served | surfaces immediately as an error |
| Suits | exploration, internal tools, "just answer" clients | production clients with a strict contract |
For fail safe to be smart, turn on smart-routing-enabled and name the tiers (see below). Without them the path is
the default model, then the semantic group match, then the cheapest allowed model. That is safe in that it never
leaves the key's allowed set, but it is not quality-aware. An unknown value refuses the boot.
Per key and per tenant. The setting above is the gateway-wide default. A key may override it with omitted_model
(route or refuse) on PUT /admin/keys/{keyId}/policy; null clears the override back to the global setting. The
default model is chosen the same way, nearest first: the key's default_model, else the tenant's (PUT
/admin/organizations/{orgId}/default-model), else the rest of the ladder. A tenant default is a name like any other:
it resolves through the key's allowed set, so a tenant default the key may not use is not served.
In the dashboard, a key's detail panel has a Model resolution section: each value, where it comes from (the key, the tenant or the gateway), and an editor for an admin or a tenant admin of the key's organisation. The tenant default is under Control > Model defaults for a tenant admin, and behind Model defaults on the organisation roster for an admin, with how many keys use it.
When the caller names a model#
A request that names one concrete model is served by that model (with the fallback ladder behind it). No strategy chooses, and by default no bandit hears of it.
Learning from it: pointsman.bandit.learn-from-direct (POINTSMAN_BANDIT_LEARN_FROM_DIRECT), off by default.
Under observe, every strategy: bandit group with that exact provider:model member is told what the call cost,
how long it took and whether it returned:
| Group objective | What a direct call teaches it |
|---|---|
cost |
the call's priced usage, or its failure, enters that member's cost estimate. The other members are not discounted, because no choice among them was made |
balanced |
one completion or failure reward goes to the policy service under the request's tenant and task type, for that member, marked provenance: direct |
The tradeoffs:
off (default) |
observe |
|
|---|---|---|
| Bandit groups warm up | only from their own traffic | also from direct traffic to their members |
| What is learned | cost, latency, length, success. Never quality: a direct response carries no X-Decision-Id, so no rating can attach to it, and quality-guarded profiles never see it |
|
| Bias | none added | callers choose models for their hard prompts, so a member's direct calls describe those prompts as much as the model. A model used directly for long work looks dear and slow to a group that sends it short work |
| Policy-service traffic | unchanged | one reward per direct call to a balanced group's member |
A model behind another provider is not the member, and teaches the group nothing. Group requests are unaffected either way.
Semantic routing#
Think of a dispatcher who has read every team's one-paragraph job description and routes each incoming request to the team whose description it most resembles. You write the descriptions; the matching is automatic.
Give a group a description: - a sentence saying what traffic belongs there, in the words
that traffic actually uses - and the gateway embeds that sentence once at startup, exactly the
way the semantic cache embeds prompts. When a blank-model request arrives, the latest user
turn is embedded and compared against every group description by cosine similarity. The best
match wins if it clears pointsman.routing.semantic-threshold (0.75 by default); the response
then discloses the choice:
X-Resolved-Model: sql-helpers (semantic)
X-Semantic-Group: sql-helpers
Below the threshold, or when no group has a description, or while the embedder is still warming after boot, the request simply falls through to the mode tiers - semantic routing degrades to absence, never to an error. A matched group is also intersected with what the key may use: a match whose members are all disallowed or incapable falls through the same way, because a description is a suggestion and the allow-set is a rule.
| Aspect | Semantic group routing |
|---|---|
| Selection input | Latest user turn compared with operator-written group descriptions |
| Configuration | A description: on each participating group; changes require restart |
| Embedding dependency | Requires a useful semantic embedder; lexical hashing typically does not clear the similarity threshold |
| Feedback | Not used for group matching; the selected group's own strategy can still learn |
| Selection rule | Deterministic similarity matching for the same vectors and configuration |
| Cost | One embedding on this path. It is separate from the cache's redacted, context-scoped text, so a subsequent semantic-cache lookup can require another embedding |
Choose descriptions and callers with these limits in mind:
- A poor description can select an unsuitable group; inspect
X-Semantic-Groupon responses. - Matching uses the current turn alone. A follow-up such as “make it shorter” can miss the original group or select another one.
- Use a named group when conversational traffic must retain that group. Evaluate semantic selection on representative single-turn traffic.
The mode tiers#
After semantic routing, smart routing (off by default) profiles the request, infers a
mode (cost / balanced / quality - overridable per request with X-Routing-Mode), and enters
the tier you configured for that mode, cascading to cheaper tiers when one is absent. Unknown
model names enter the cost tier the same way, disclosed via X-Model-Resolution: fallback.
The extension point#
A strategy is a Spring bean implementing RoutingStrategy
(gateway/src/main/java/dev/pointsman/gateway/routing/strategy/). Register a @Component
and its bean name is a legal strategy: value - the boot-time vocabulary check accepts any
registered bean.