feat: implement tiered billing expression evaluation and related functionality

- Added support for tiered billing expressions in the billing system.
- Introduced new types and functions for handling billing expressions, including caching and execution.
- Updated existing billing logic to accommodate tiered billing scenarios.
- Enhanced request handling to support incoming billing expression requests.
- Added tests for tiered billing functionality to ensure correctness.
This commit is contained in:
CaIon
2026-03-16 16:00:22 +08:00
parent a4fd2246ba
commit 91ed4e196a
34 changed files with 4797 additions and 26 deletions
@@ -1,5 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { API, showError, showSuccess } from '../../../../helpers';
import {
combineBillingExpr,
splitBillingExprAndRequestRules,
} from '../components/requestRuleExpr';
export const PAGE_SIZE = 10;
export const PRICE_SUFFIX = '$/1M tokens';
@@ -18,6 +22,8 @@ const EMPTY_MODEL = {
imagePrice: '',
audioInputPrice: '',
audioOutputPrice: '',
billingExpr: '',
requestRuleExpr: '',
rawRatios: {
modelRatio: '',
completionRatio: '',
@@ -98,6 +104,22 @@ const normalizeCompletionRatioMeta = (rawMeta) => {
};
const buildModelState = (name, sourceMaps) => {
const billingMode = sourceMaps.ModelBillingMode?.[name];
if (billingMode === 'tiered_expr') {
const fullBillingExpr = sourceMaps.ModelBillingExpr?.[name] || '';
const { billingExpr, requestRuleExpr } =
splitBillingExprAndRequestRules(fullBillingExpr);
return {
...EMPTY_MODEL,
name,
billingMode: 'tiered_expr',
billingExpr,
requestRuleExpr,
rawRatios: { ...EMPTY_MODEL.rawRatios },
hasConflict: false,
};
}
const modelRatio = toNumericString(sourceMaps.ModelRatio[name]);
const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]);
const completionRatioMeta = normalizeCompletionRatioMeta(
@@ -159,6 +181,7 @@ const buildModelState = (name, sourceMaps) => {
toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio)
? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio))
: '',
requestRuleExpr: '',
rawRatios: {
modelRatio,
completionRatio,
@@ -183,12 +206,16 @@ const buildModelState = (name, sourceMaps) => {
};
export const isBasePricingUnset = (model) =>
model.billingMode !== 'tiered_expr' &&
!hasValue(model.fixedPrice) && !hasValue(model.inputPrice);
export const getModelWarnings = (model, t) => {
if (!model) {
return [];
}
if (model.billingMode === 'tiered_expr') {
return [];
}
const warnings = [];
const hasDerivedPricing = [
model.inputPrice,
@@ -244,8 +271,22 @@ export const getModelWarnings = (model, t) => {
};
export const buildSummaryText = (model, t) => {
const requestRuleSuffix =
model.billingMode === 'tiered_expr' && model.requestRuleExpr
? `${t('请求规则')}`
: '';
if (model.billingMode === 'tiered_expr') {
const expr = model.billingExpr;
if (!expr) return `${t('表达式计费')}${requestRuleSuffix}`;
const tierCount = (expr.match(/tier\(/g) || []).length;
if (tierCount === 0) {
return `${t('表达式计费')}${requestRuleSuffix}`;
}
return `${t('阶梯计费')} (${tierCount} ${t('档')})${requestRuleSuffix}`;
}
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
return `${t('按次')} $${model.fixedPrice} / ${t('次')}`;
return `${t('按次')} $${model.fixedPrice} / ${t('次')}${requestRuleSuffix}`;
}
if (hasValue(model.inputPrice)) {
@@ -259,10 +300,10 @@ export const buildSummaryText = (model, t) => {
].filter(hasValue).length;
const extraLabel =
extraCount > 0 ? `${t('额外价格项')} ${extraCount}` : '';
return `${t('输入')} $${model.inputPrice}${extraLabel}`;
return `${t('输入')} $${model.inputPrice}${extraLabel}${requestRuleSuffix}`;
}
return t('未设置价格');
return `${t('未设置价格')}${requestRuleSuffix}`;
};
export const buildOptionalFieldToggles = (model) => ({
@@ -395,20 +436,53 @@ const serializeModel = (model, t) => {
export const buildPreviewRows = (model, t) => {
if (!model) return [];
const finalBillingExpr = combineBillingExpr(
model.billingExpr,
model.requestRuleExpr,
);
if (model.billingMode === 'tiered_expr') {
const rows = [
{
key: 'BillingMode',
label: 'ModelBillingMode',
value: 'tiered_expr',
},
];
if (finalBillingExpr) {
const tierCount = (model.billingExpr.match(/tier\(/g) || []).length;
rows.push({
key: 'BillingExpr',
label: 'ModelBillingExpr',
value:
tierCount > 0
? `${tierCount} ${t('档')}${
finalBillingExpr.length > 60
? finalBillingExpr.slice(0, 60) + '...'
: finalBillingExpr
}`
: finalBillingExpr.length > 60
? finalBillingExpr.slice(0, 60) + '...'
: finalBillingExpr,
});
}
return rows;
}
if (model.billingMode === 'per-request') {
return [
const rows = [
{
key: 'ModelPrice',
label: 'ModelPrice',
value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'),
},
];
return rows;
}
const inputPrice = toNumberOrNull(model.inputPrice);
if (inputPrice === null) {
return [
const rows = [
{
key: 'ModelRatio',
label: 'ModelRatio',
@@ -459,6 +533,7 @@ export const buildPreviewRows = (model, t) => {
: t('空'),
},
];
return rows;
}
const completionPrice = toNumberOrNull(model.completionPrice);
@@ -468,7 +543,7 @@ export const buildPreviewRows = (model, t) => {
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
return [
const rows = [
{
key: 'ModelRatio',
label: 'ModelRatio',
@@ -522,6 +597,7 @@ export const buildPreviewRows = (model, t) => {
: t('空'),
},
];
return rows;
};
export function useModelPricingEditorState({
@@ -552,6 +628,8 @@ export function useModelPricingEditorState({
ImageRatio: parseOptionJSON(options.ImageRatio),
AudioRatio: parseOptionJSON(options.AudioRatio),
AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio),
ModelBillingMode: parseOptionJSON(options.ModelBillingMode),
ModelBillingExpr: parseOptionJSON(options.ModelBillingExpr),
};
const names = new Set([
@@ -565,6 +643,8 @@ export function useModelPricingEditorState({
...Object.keys(sourceMaps.ImageRatio),
...Object.keys(sourceMaps.AudioRatio),
...Object.keys(sourceMaps.AudioCompletionRatio),
...Object.keys(sourceMaps.ModelBillingMode),
...Object.keys(sourceMaps.ModelBillingExpr),
]);
const nextModels = Array.from(names)
@@ -775,10 +855,29 @@ export function useModelPricingEditorState({
};
const handleBillingModeChange = (value) => {
if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => {
const next = { ...model, billingMode: value };
if (value === 'tiered_expr' && !model.billingExpr) {
next.billingExpr = 'tier("default", p * 0 + c * 0)';
}
return next;
});
};
const handleBillingExprChange = (newExpr) => {
if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => ({
...model,
billingMode: value,
billingExpr: newExpr,
}));
};
const handleRequestRuleExprChange = (newExpr) => {
if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => ({
...model,
requestRuleExpr: newExpr,
}));
};
@@ -854,6 +953,8 @@ export function useModelPricingEditorState({
imagePrice: selectedModel.imagePrice,
audioInputPrice: selectedModel.audioInputPrice,
audioOutputPrice: selectedModel.audioOutputPrice,
billingExpr: selectedModel.billingExpr || '',
requestRuleExpr: selectedModel.requestRuleExpr || '',
};
if (
@@ -915,7 +1016,26 @@ export function useModelPricingEditorState({
AudioCompletionRatio: {},
};
const tieredOutput = {
ModelBillingMode: {},
ModelBillingExpr: {},
};
for (const model of models) {
if (model.billingMode === 'tiered_expr') {
tieredOutput.ModelBillingMode[model.name] = 'tiered_expr';
const finalBillingExpr = combineBillingExpr(
model.billingExpr,
model.requestRuleExpr,
);
if (finalBillingExpr) {
tieredOutput.ModelBillingExpr[model.name] = finalBillingExpr;
}
}
if (model.billingMode === 'tiered_expr') {
continue;
}
const serialized = serializeModel(model, t);
Object.entries(serialized).forEach(([key, value]) => {
if (value !== null) {
@@ -924,12 +1044,20 @@ export function useModelPricingEditorState({
});
}
const requestQueue = Object.entries(output).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
}),
);
const requestQueue = [
...Object.entries(output).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
}),
),
...Object.entries(tieredOutput).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
}),
),
];
const results = await Promise.all(requestQueue);
for (const res of results) {
@@ -970,6 +1098,8 @@ export function useModelPricingEditorState({
handleOptionalFieldToggle,
handleNumericFieldChange,
handleBillingModeChange,
handleBillingExprChange,
handleRequestRuleExprChange,
handleSubmit,
addModel,
deleteModel,