Skip to content

Pricing tiers for candidates, with routing deliberately left on list price - #269

Merged
bsbodden merged 1 commit into
mainfrom
feature/tiered-pricing
Oct 16, 2026
Merged

bsbodden merged 1 commit into
mainfrom
feature/tiered-pricing

Conversation

@bsbodden

Copy link
Copy Markdown
Member

The gap

A ModelCandidate declares one pair of rates, and providers do not all charge one rate. Anthropic's Haiku 5.5 is $0.10/$0.50 per million below a 100,000-token prompt and $0.50/$2.50 at or above it. Declared flat, a 150,000-token request prices five-fold low — and because RoutingBudget charges whatever ModelCandidate.costOf reports, a budget declared that way stops spending later than it was told to.

The routing fleet written for the Models deep-dive hit this and could only document it.

What this adds

PricingTier and ModelCandidate.pricingTiers, with tierFor(inputTokens) returning the tier in force and costOf applying it. Crossing a threshold reprices the whole request, output included, not only the tokens above it.

Semantics taken from the implementations, read not assumed

  • LiteLLM keys tiers on prompt tokens, sorts thresholds descending and takes the first match, and applies the tier rate to every token type. Its comparison is exclusive by default with a threshold_is_inclusive flag "for providers such as xAI that bill the higher tier once the prompt reaches the threshold".
  • Ours is inclusive on an integer count, so "above 100,000" is declared as 100_001. The flag exists there because LiteLLM's thresholds are floats; integers make it unnecessary.

What deliberately does not change

blendedCostPerMillionTokens still reports list price, and the scorer still uses it. cheapness normalises cost across the eligible candidates, so a rate that moved with prompt size would make a candidate's score jump at its threshold and its rank non-monotonic in request size. Accounting is where the tier must be right.

weave-os/router draws the same line in production: a dispatch is billed at the tier that served it while "routing still scores on list price".

For completeness: vllm-semantic-router carries flat per-token prices in config with no tiers, and RouteLLM has no runtime price model at all. Tiers live in the implementations that do the billing, not the ones that do the selecting.

Verification

Both new tests mutation-checked — applying a tier to input only, and taking the lowest applicable tier rather than the highest, each fail them. Backward compatible: the previous arity remains as a constructor overload and a candidate without tiers behaves exactly as before. test, build, spotlessCheck, complianceCheck, jacocoTestCoverageVerification pass.

Minor version because this adds API. Prices remain the operator's declaration; this library still does not fetch a price map at startup, which is a deployment decision rather than a library one.

A single pair of rates cannot describe a provider that charges in steps.
Anthropic's Haiku 5.5 is $0.10 and $0.50 per million below a 100,000-token prompt
and $0.50 and $2.50 at or above it. Declared flat, a 150,000-token request prices
five-fold low, and since RoutingBudget charges whatever ModelCandidate.costOf
reports, a budget declared that way stops spending later than it was told to.
The demo written for the Models article hit exactly this and had to document the
gap rather than express it.

`PricingTier` plus `ModelCandidate.pricingTiers` closes it. `tierFor` returns the
tier in force and `costOf` applies it. Crossing a threshold reprices the whole
request, output included, rather than only the tokens above it.

The semantics follow what the implementations actually do, read rather than
assumed. LiteLLM keys tiers on prompt tokens, sorts thresholds descending and
takes the first match, and repriced requests use the tier rate for every token
type; its comparison is exclusive by default with a threshold_is_inclusive flag
"for providers such as xAI that bill the higher tier once the prompt reaches the
threshold". Ours is inclusive on an integer count, so "above 100,000" is declared
as 100_001 and no flag is needed -- the flag exists there because the thresholds
are floats.

Routing deliberately does not change. `blendedCostPerMillionTokens` still reports
list price and the scorer still uses it, because `cheapness` normalises cost
across the eligible candidates: a rate that moved with prompt size would make a
candidate's score jump at its threshold and its rank non-monotonic in the size of
the request. Accounting is where the tier has to be right. weave-os/router draws
the same line in a production gateway -- a dispatch is billed at the tier that
served it while "routing still scores on list price".

Also surveyed, for the record: vllm-semantic-router carries flat per-token prices
in config with no tiers, and RouteLLM has no runtime price model at all, scoring
on predicted quality with cost appearing only in offline evaluation. Tiers live
in the implementations that do the billing, not the ones that do the selecting.

Both new tests were mutation-checked: applying a tier to input only, and taking
the lowest applicable tier instead of the highest, each fail them. The list is
copied with List.copyOf of a sorted copy rather than stream().toList() so the
static analysis recognises the defensive copy. test, build, spotlessCheck,
complianceCheck and jacocoTestCoverageVerification all pass.
@bsbodden
bsbodden merged commit e4f30c1 into main Oct 16, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant