fix: stop a sub-model inheriting its family's price

This commit is contained in:
hugocasa
2026-08-13 20:29:14 +02:00
parent 48f01b4c37
commit 379ebdd24a
3 changed files with 46 additions and 12 deletions
@@ -129,10 +129,24 @@ function normalizeVersionSeparators(model: string): string {
* Family fallbacks ending on a letter get no such guard — a version welded
* straight onto the name (`llama3.1`) is exactly what they exist to catch.
*/
export function buildModelMatchers<T>(entries: [name: string, value: T][]): [RegExp, T][] {
export function buildModelMatchers<T>(
entries: [name: string, value: T][],
{ strictVariants = false }: { strictVariants?: boolean } = {}
): [RegExp, T][] {
return entries.map(([name, value]) => {
const pattern = normalizeVersionSeparators(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return [new RegExp(/\d$/.test(pattern) ? `${pattern}(?!\\d)` : pattern), value]
const guards = [
// An entry ending on a version digit must not run into a longer version.
/\d$/.test(pattern) ? '(?!\\d)' : '',
// A named sub-model (`gpt-5-pro`, `gpt-5-mini`) is a different model with
// its own price, not another route to this one — so under strictVariants an
// entry does not match when a further *name* segment follows. Date suffixes
// (`-20251101`) and Bedrock's `-v1` are route decorations, not sub-models,
// and still match. Off by default: for a context window an inherited value
// is a safe approximation, for a price it is a wrong number.
strictVariants ? '(?!-(?!v\\d)[a-z])' : ''
].join('')
return [new RegExp(pattern + guards), value]
})
}
@@ -27,15 +27,26 @@ describe('resolveModelPrice', () => {
expect(resolveModelPrice('customai', 'some-in-house-model', undefined)).toBeUndefined()
})
it('does not let a newer revision inherit an older ones price', () => {
// `gpt-5.6` normalizes to `gpt-5-6`, which the `gpt-5` entry would otherwise
// claim — pricing a model we do not track at a rate that is several times off.
it('does not let another model inherit a price through a shared prefix', () => {
// A sub-model (`-pro`) or a newer revision (`gpt-5.6` → `gpt-5-6`) is a
// different model at a different rate; inheriting `gpt-5`'s would be off by
// an order of magnitude, and silently so.
expect(resolveModelPrice('openai', 'gpt-5', undefined)?.price.input).toBe(1.25)
expect(resolveModelPrice('openai', 'gpt-5-mini', undefined)?.price.input).toBe(0.25)
expect(resolveModelPrice('openai', 'gpt-5-pro', undefined)).toBeUndefined()
expect(resolveModelPrice('openai', 'gpt-5.6', undefined)).toBeUndefined()
expect(resolveModelPrice('googleai', 'gemini-3.1', undefined)).toBeUndefined()
})
it('still resolves the route decorations that name the same model', () => {
// Dates and Bedrock's -v1 are ways of spelling one model, not sub-models.
expect(resolveModelPrice('anthropic', 'claude-opus-4-5-20251101', undefined)?.price.input).toBe(5)
expect(
resolveModelPrice('bedrock', 'anthropic.claude-sonnet-4-6-20250101-v1:0', undefined)?.price
.input
).toBe(3)
})
it('prefers a workspace override, keeping the models own cache ratios', () => {
const resolved = resolveModelPrice('anthropic', 'claude-opus-5', {
'anthropic:claude-opus-5': { input: 2, output: 8 }
@@ -53,10 +53,17 @@ type PriceEntry = {
* Providers whose catalogue turns over too quickly to track (DeepSeek, Mistral,
* Groq, TogetherAI, custom deployments) are deliberately absent.
*
* `null` marks a model that is known to exist but whose rates are not: it stops a
* newer revision from falling through to an older one's family entry and being
* quietly mispriced. Unpriced is a supported state (the UI says so and points at
* the override); a confidently wrong number is not.
* `null` marks a model that is known to exist but whose rates are not. Unpriced is
* a supported state (the UI says so and points at the override); a confidently
* wrong number is not — which is also why these matchers are built with
* `strictVariants`, so an unlisted sub-model (`gpt-5-pro`) reports no rate instead
* of inheriting its family's.
*
* One known gap the per-model shape cannot express: a few vendors charge more
* above a context threshold (Gemini 2.5 Pro past 200k input, Anthropic's 1M-context
* beta). Usage is aggregated per model before pricing, so those requests are
* estimated at the standard tier and understate. An affected workspace can set the
* higher rate as its override.
*/
const MODEL_PRICES: [name: string, price: PriceEntry | null][] = [
// Anthropic — Opus 4.1 and older bill at the pre-4.5 Opus rate, so the family
@@ -84,8 +91,9 @@ const MODEL_PRICES: [name: string, price: PriceEntry | null][] = [
// rate. There is no charge for writing the cache and no usage field reporting
// one, so the write rate never applies. The -mini/-nano entries must precede
// the family entry, which would otherwise claim them.
// Revisions past gpt-5 are priced separately by OpenAI and are not tracked here;
// without these the `gpt-5` entry below would claim them at gpt-5's rates.
// Revisions past gpt-5 are priced separately by OpenAI and are not tracked here.
// `strictVariants` does not cover these: a version bump is digits, not a name
// segment, so `gpt-5-6` would otherwise still match `gpt-5`.
['gpt-5.6', null],
['gpt-5.5', null],
['gpt-5.4', null],
@@ -120,7 +128,8 @@ const MODEL_PRICE_MATCHERS = buildModelMatchers(
cacheRead: entry.cacheRead ?? entry.input * CACHE_READ_RATIO,
cacheWrite: entry.cacheWrite ?? entry.input * CACHE_WRITE_RATIO
}
])
]),
{ strictVariants: true }
)
export function getKnownModelPrice(model: string): ModelPrice | undefined {