fix(pricing): classify dynamic per-request billing correctly (#8010)

* fix(pricing): classify dynamic per-request billing correctly

* fix(pricing): classify mixed billing as token-based
This commit is contained in:
Seefs authored and GitHub committed 2026-10-25 20:41:57 +08:00
1 parent bc795cc118
commit f1569d1ea7
3 files changed
+169 -21

No files matched your search

@@ -25,6 +25,7 @@ import {
PricingToolbar,
type PricingToolbarProps,
} from '../components/pricing-toolbar'
import { filterByQuotaType } from '../lib/filters'
import type { PricingModel } from '../types'
function toolbarProps(): PricingToolbarProps {
@@ -61,6 +62,133 @@ function toolbarProps(): PricingToolbarProps {
}
describe('pricing controls', () => {
it.each<{
name: string
pricing: Partial<PricingModel>
matches: string[]
}>([
{ name: 'legacy token pricing', pricing: {}, matches: ['token'] },
{
name: 'legacy per-request pricing',
pricing: { quota_type: 1, model_price: 0.01 },
matches: ['request'],
},
{
name: 'dynamic fixed pricing with a legacy token quota type',
pricing: {
billing_mode: 'tiered_expr',
billing_expr: 'tier("request", fixed(0.01))',
},
matches: ['request'],
},
{
name: 'free dynamic per-request pricing',
pricing: {
billing_mode: 'tiered_expr',
billing_expr: 'v1:tier("free", fixed(0))',
},
matches: ['request'],
},
{
name: 'request-dependent fixed tiers with an extra multiplier',
pricing: {
billing_mode: 'tiered_expr',
billing_expr:
'(param("quality") == "hd" ? tier("hd", fixed(0.02)) : tier("base", fixed(0.01))) * (header("x-fast") == "true" ? 2 : 1)',
},
matches: ['request'],
},
{
name: 'mixed pricing across nested time tiers classified only as token-based',
pricing: {
billing_mode: 'tiered_expr',
billing_expr:
'hour("UTC") < 12 ? (len < 1000 ? tier("short", fixed(0.01)) : tier("long", p * 2 + c * 8)) : tier("night", fixed(0.02))',
},
matches: ['token'],
},
{
name: 'mixed pricing overriding a legacy request quota type',
pricing: {
quota_type: 1,
billing_mode: 'tiered_expr',
billing_expr:
'len < 1000 ? tier("short", fixed(0.01)) : tier("long", p * 2 + c * 8)',
},
matches: ['token'],
},
{
name: 'dynamic token pricing overriding a legacy request quota type',
pricing: {
quota_type: 1,
billing_mode: 'tiered_expr',
billing_expr: 'tier("base", p * 2 + c * 8)',
},
matches: ['token'],
},
{
name: 'token pricing with fixed syntax only in its tier label',
pricing: {
billing_mode: 'tiered_expr',
billing_expr: 'tier("fixed(0.01)", p * 2)',
},
matches: ['token'],
},
{
name: 'task usage retaining its separate category',
pricing: {
billing_mode: 'tiered_expr',
billing_expr: 'tier("base", u("seconds") * 0.4)',
billing_usage_schema: { seconds: { type: 'number', unit: 'second' } },
},
matches: ['task'],
},
{
name: 'unparseable dynamic pricing retaining its legacy category',
pricing: {
quota_type: 1,
billing_mode: 'tiered_expr',
billing_expr: 'tier("request", fixed(',
},
matches: ['request'],
},
])(
'keeps filter results and sidebar counts consistent for $name',
({ pricing, matches }) => {
const model: PricingModel = {
id: 1,
model_name: 'pricing-model',
quota_type: 0,
model_ratio: 1,
completion_ratio: 1,
enable_groups: ['default'],
...pricing,
}
const models = [model]
render(<PricingSidebar {...toolbarProps()} models={models} />)
for (const [quotaType, label] of [
['token', 'Token-based'],
['request', 'Per Request'],
['task', 'Task billing'],
]) {
const included = matches.includes(quotaType)
expect(filterByQuotaType(models, quotaType)).toEqual(
included ? [model] : []
)
expect(
screen.getByRole('button', {
name: new RegExp(`^${label}\\s*${included ? 1 : 0}$`),
})
).toBeVisible()
}
expect(filterByQuotaType(models, 'all')).toEqual([model])
expect(
screen.getByRole('button', { name: /^All Models\s*1$/ })
).toBeVisible()
}
)
it('counts each model once per filter and updates counts when the catalog changes', () => {
const props = toolbarProps()
const base: PricingModel = {
@@ -37,8 +37,7 @@ import {
getEndpointTypeLabels,
getQuotaTypeLabels,
} from '../constants'
import { hasTaskUsageSchema } from '../lib/dynamic-price'
import { parseTags } from '../lib/filters'
import { getModelQuotaType, parseTags } from '../lib/filters'
import type { PricingModel, PricingVendor } from '../types'
type FilterOption = {
@@ -172,13 +171,8 @@ export const PricingSidebar = memo(function PricingSidebar(
for (const endpoint of new Set(model.supported_endpoint_types ?? [])) {
endpoints.set(endpoint, (endpoints.get(endpoint) ?? 0) + 1)
}
if (hasTaskUsageSchema(model)) {
quotas.task++
} else if (model.quota_type === 0) {
quotas.token++
} else if (model.quota_type === 1) {
quotas.request++
}
const quotaType = getModelQuotaType(model)
if (quotaType) quotas[quotaType]++
}
return { vendors, tags, endpoints, quotas }
}, [props.models])
+38 -12
View File
@@ -22,9 +22,12 @@ import {
QUOTA_TYPES,
QUOTA_TYPE_VALUES,
ENDPOINT_TYPES,
type QuotaTypeOption,
type SortOption,
} from '../constants'
import type { PricingModel } from '../types'
import { compileBillingExpression } from './billing-expression/parser'
import { visitExpression } from './billing-expression/types'
import {
getCardExamplePrice,
getDynamicPricingSummary,
@@ -79,6 +82,39 @@ export function filterByGroup(
return models.filter((m) => m.enable_groups?.includes(group))
}
/** Pricing category of the active configuration, shared by filters and counts. */
export function getModelQuotaType(
model: PricingModel
): Exclude<QuotaTypeOption, 'all'> | undefined {
// Task-usage models retain their own category.
if (hasTaskUsageSchema(model)) return QUOTA_TYPES.TASK
if (model.billing_mode === 'tiered_expr' && model.billing_expr) {
const compiled = compileBillingExpression(model.billing_expr)
if (compiled.status === 'ready') {
if (!compiled.functions.has('fixed')) return QUOTA_TYPES.TOKEN
// Only models whose pricing leaves are all fixed count as per-request.
let hasTokenPricing = false
visitExpression(compiled.ast, (node) => {
if (node.kind !== 'call' || node.name !== 'tier') return
const price = node.args[1]
if (price.kind !== 'call' || price.name !== 'fixed') {
hasTokenPricing = true
}
})
return hasTokenPricing ? QUOTA_TYPES.TOKEN : QUOTA_TYPES.REQUEST
}
}
// Preserve legacy classification when the expression cannot be parsed.
if (model.quota_type === QUOTA_TYPE_VALUES.TOKEN) return QUOTA_TYPES.TOKEN
if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) {
return QUOTA_TYPES.REQUEST
}
return undefined
}
/**
* Filter models by quota type
*/
@@ -87,17 +123,7 @@ export function filterByQuotaType(
quotaType: string
): PricingModel[] {
if (quotaType === QUOTA_TYPES.ALL) return models
// Task-usage models form their own bucket, disjoint from token/request.
if (quotaType === QUOTA_TYPES.TASK) {
return models.filter((m) => hasTaskUsageSchema(m))
}
const targetType =
quotaType === QUOTA_TYPES.TOKEN
? QUOTA_TYPE_VALUES.TOKEN
: QUOTA_TYPE_VALUES.REQUEST
return models.filter(
(m) => m.quota_type === targetType && !hasTaskUsageSchema(m)
)
return models.filter((model) => getModelQuotaType(model) === quotaType)
}
/**
@@ -250,7 +276,7 @@ export function extractAllTags(models: PricingModel[]): string[] {
}
})
return Array.from(tagSet).sort((a, b) => a.localeCompare(b))
return [...tagSet].sort((a, b) => a.localeCompare(b))
}
/**