mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
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:
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])
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user