feat: enhance tiered billing functionality and UI components

- Introduced new fields for billing mode and expression in the Pricing model.
- Implemented dynamic pricing breakdown component to display tiered billing details.
- Updated various components to support and render tiered billing information.
- Enhanced pricing calculation logic to accommodate dynamic pricing scenarios.
- Added tests for new billing expression functionalities and UI components.
This commit is contained in:
CaIon
2026-03-17 16:59:25 +08:00
parent 91ed4e196a
commit f0589cc478
23 changed files with 1237 additions and 695 deletions
@@ -26,6 +26,7 @@ import ModelHeader from './components/ModelHeader';
import ModelBasicInfo from './components/ModelBasicInfo';
import ModelEndpoints from './components/ModelEndpoints';
import ModelPricingTable from './components/ModelPricingTable';
import DynamicPricingBreakdown from './components/DynamicPricingBreakdown';
const { Text } = Typography;
@@ -89,6 +90,12 @@ const ModelDetailSideSheet = ({
endpointMap={endpointMap}
t={t}
/>
{modelData.billing_mode === 'tiered_expr' && modelData.billing_expr && (
<DynamicPricingBreakdown
billingExpr={modelData.billing_expr}
t={t}
/>
)}
<ModelPricingTable
modelData={modelData}
groupRatio={groupRatio}
@@ -0,0 +1,293 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Card, Avatar, Tag, Table, Typography } from '@douyinfe/semi-ui';
import { IconPriceTag } from '@douyinfe/semi-icons';
import {
splitBillingExprAndRequestRules,
tryParseRequestRuleExpr,
SOURCE_TIME,
MATCH_RANGE,
MATCH_EQ,
MATCH_GTE,
MATCH_LT,
MATCH_CONTAINS,
MATCH_EXISTS,
} from '../../../../../pages/Setting/Ratio/components/requestRuleExpr';
const { Text } = Typography;
const PRICE_SUFFIX = '$/1M tokens';
function unitCostToPrice(uc) {
return (Number(uc) || 0) * 2;
}
function formatPrice(uc) {
const p = unitCostToPrice(uc);
return p ? `$${p.toFixed(4)}` : '-';
}
const VAR_LABELS = { p: '输入', c: '输出' };
const OP_LABELS = { '<': '<', '<=': '≤', '>': '>', '>=': '≥' };
const TIME_FUNC_LABELS = { hour: '小时', minute: '分钟', weekday: '星期', month: '月份', day: '日期' };
function formatTokenHint(value) {
const n = Number(value);
if (!Number.isFinite(n) || n === 0) return '';
if (n >= 1000000) return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}K`;
return String(n);
}
function formatConditionSummary(conditions, t) {
return conditions
.map((c) => {
if (c.var && c.op) {
const varLabel = t(VAR_LABELS[c.var] || c.var);
const hint = formatTokenHint(c.value);
return `${varLabel} ${OP_LABELS[c.op] || c.op} ${hint || c.value}`;
}
return '';
})
.filter(Boolean)
.join(' && ');
}
function tryParseTiers(baseExpr) {
if (!baseExpr) return null;
try {
const cacheVars = ['cr', 'cc', 'cc1h'];
const optCache = cacheVars.map((v) => `(?:\\s*\\+\\s*${v}\\s*\\*\\s*([\\d.eE+-]+))?`).join('');
const bodyPat = `p\\s*\\*\\s*([\\d.eE+-]+)\\s*\\+\\s*c\\s*\\*\\s*([\\d.eE+-]+)${optCache}`;
const singleRe = new RegExp(`^tier\\("([^"]*)",\\s*${bodyPat}\\)$`);
const simple = baseExpr.match(singleRe);
if (simple) {
return [{
label: simple[1],
conditions: [],
inputPrice: unitCostToPrice(Number(simple[2])),
outputPrice: unitCostToPrice(Number(simple[3])),
cacheReadPrice: simple[4] ? unitCostToPrice(Number(simple[4])) : null,
cacheCreatePrice: simple[5] ? unitCostToPrice(Number(simple[5])) : null,
cacheCreate1hPrice: simple[6] ? unitCostToPrice(Number(simple[6])) : null,
}];
}
const condGroup = `((?:(?:p|c)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)(?:\\s*&&\\s*(?:p|c)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`;
const tierRe = new RegExp(`(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*${bodyPat}\\)`, 'g');
const tiers = [];
let match;
while ((match = tierRe.exec(baseExpr)) !== null) {
const condStr = match[1] || '';
const conditions = [];
if (condStr) {
for (const cp of condStr.split(/\s*&&\s*/)) {
const cm = cp.trim().match(/^(p|c)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/);
if (cm) conditions.push({ var: cm[1], op: cm[2], value: Number(cm[3]) });
}
}
tiers.push({
label: match[2],
conditions,
inputPrice: unitCostToPrice(Number(match[3])),
outputPrice: unitCostToPrice(Number(match[4])),
cacheReadPrice: match[5] ? unitCostToPrice(Number(match[5])) : null,
cacheCreatePrice: match[6] ? unitCostToPrice(Number(match[6])) : null,
cacheCreate1hPrice: match[7] ? unitCostToPrice(Number(match[7])) : null,
});
}
return tiers.length > 0 ? tiers : null;
} catch {
return null;
}
}
function describeCondition(cond, t) {
if (cond.source === SOURCE_TIME) {
const fn = t(TIME_FUNC_LABELS[cond.timeFunc] || cond.timeFunc);
const tz = cond.timezone || 'UTC';
if (cond.mode === MATCH_RANGE) {
return `${fn} ${cond.rangeStart}:00~${cond.rangeEnd}:00 (${tz})`;
}
const opMap = { [MATCH_EQ]: '=', [MATCH_GTE]: '≥', [MATCH_LT]: '<' };
return `${fn} ${opMap[cond.mode] || '='} ${cond.value} (${tz})`;
}
const src = cond.source === 'header' ? t('请求头') : t('请求参数');
const path = cond.path || '';
if (cond.mode === MATCH_EXISTS) return `${src} ${path} ${t('存在')}`;
if (cond.mode === MATCH_CONTAINS) return `${src} ${path} ${t('包含')} "${cond.value}"`;
const opMap = { eq: '=', gt: '>', gte: '≥', lt: '<', lte: '≤' };
return `${src} ${path} ${opMap[cond.mode] || '='} ${cond.value}`;
}
function describeGroup(group, t) {
const parts = (group.conditions || []).map((c) => describeCondition(c, t));
return parts.join(' && ');
}
export default function DynamicPricingBreakdown({ billingExpr, t }) {
const { billingExpr: baseExpr, requestRuleExpr: ruleExpr } =
splitBillingExprAndRequestRules(billingExpr || '');
const tiers = tryParseTiers(baseExpr);
const ruleGroups = tryParseRequestRuleExpr(ruleExpr || '');
const hasTiers = tiers && tiers.length > 0;
const hasRules = ruleGroups && ruleGroups.length > 0;
if (!hasTiers && !hasRules) {
return (
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-3'>
<Avatar size='small' color='amber' className='mr-2 shadow-md'>
<IconPriceTag size={16} />
</Avatar>
<Text className='text-lg font-medium'>{t('动态计费')}</Text>
</div>
<div className='text-sm text-gray-500'>
<code style={{ fontSize: 12, wordBreak: 'break-all' }}>{billingExpr}</code>
</div>
</Card>
);
}
const tierColumns = [
{
title: t('档位'),
dataIndex: 'label',
render: (text, record) => (
<div>
<Tag color='blue' size='small'>{text || t('默认')}</Tag>
{record.condSummary && (
<div className='text-xs text-gray-500 mt-1'>{record.condSummary}</div>
)}
</div>
),
},
{
title: `${t('输入价格')} (${PRICE_SUFFIX})`,
dataIndex: 'inputPrice',
render: (v) => <Text strong>${v.toFixed(4)}</Text>,
},
{
title: `${t('输出价格')} (${PRICE_SUFFIX})`,
dataIndex: 'outputPrice',
render: (v) => <Text strong>${v.toFixed(4)}</Text>,
},
];
const hasCacheRead = hasTiers && tiers.some((tier) => tier.cacheReadPrice != null);
const hasCacheCreate = hasTiers && tiers.some((tier) => tier.cacheCreatePrice != null);
const hasCache1h = hasTiers && tiers.some((tier) => tier.cacheCreate1hPrice != null);
if (hasCacheRead) {
tierColumns.push({
title: `${t('缓存读取')} (${PRICE_SUFFIX})`,
dataIndex: 'cacheReadPrice',
render: (v) => v != null ? <Text>${v.toFixed(4)}</Text> : '-',
});
}
if (hasCacheCreate) {
tierColumns.push({
title: `${t('缓存创建')} (${PRICE_SUFFIX})`,
dataIndex: 'cacheCreatePrice',
render: (v) => v != null ? <Text>${v.toFixed(4)}</Text> : '-',
});
}
if (hasCache1h) {
tierColumns.push({
title: `${t('缓存创建-1h')} (${PRICE_SUFFIX})`,
dataIndex: 'cacheCreate1hPrice',
render: (v) => v != null ? <Text>${v.toFixed(4)}</Text> : '-',
});
}
const tierData = hasTiers
? tiers.map((tier, i) => ({
key: `tier-${i}`,
label: tier.label,
condSummary: formatConditionSummary(tier.conditions, t),
inputPrice: tier.inputPrice,
outputPrice: tier.outputPrice,
cacheReadPrice: tier.cacheReadPrice,
cacheCreatePrice: tier.cacheCreatePrice,
cacheCreate1hPrice: tier.cacheCreate1hPrice,
}))
: [];
return (
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-4'>
<Avatar size='small' color='amber' className='mr-2 shadow-md'>
<IconPriceTag size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('动态计费')}</Text>
<div className='text-xs text-gray-600'>
{t('价格根据用量档位和请求条件动态调整')}
</div>
</div>
</div>
{hasTiers && (
<div style={{ marginBottom: 16 }}>
<Text strong className='text-sm' style={{ display: 'block', marginBottom: 8 }}>
{t('分档价格表')}
</Text>
<Table
dataSource={tierData}
columns={tierColumns}
pagination={false}
size='small'
bordered={false}
className='!rounded-lg'
/>
</div>
)}
{hasRules && (
<div style={{ marginBottom: 16 }}>
<Text strong className='text-sm' style={{ display: 'block', marginBottom: 8 }}>
{t('条件乘数')}
</Text>
{ruleGroups.map((group, gi) => (
<div
key={`group-${gi}`}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
borderRadius: 6,
background: 'var(--semi-color-fill-0)',
marginBottom: 4,
}}
>
<Text size='small'>{describeGroup(group, t)}</Text>
<Tag color='orange' size='small'>{group.multiplier}x</Tag>
</div>
))}
</div>
)}
</Card>
);
}
@@ -71,11 +71,13 @@ const ModelPricingTable = ({
group: group,
ratio: groupRatioValue,
billingType:
modelData?.quota_type === 0
? t('按量计费')
: modelData?.quota_type === 1
? t('按计费')
: '-',
modelData?.billing_mode === 'tiered_expr'
? t('动态计费')
: modelData?.quota_type === 0
? t('按计费')
: modelData?.quota_type === 1
? t('按次计费')
: '-',
priceItems: getModelPriceItems(priceData, t, siteDisplayType),
};
});
@@ -94,20 +96,21 @@ const ModelPricingTable = ({
},
];
// 如果显示倍率,添加倍率列
if (showRatio) {
const isDynamic = modelData?.billing_mode === 'tiered_expr';
// 动态计费时始终显示倍率列,否则根据设置
if (showRatio || isDynamic) {
columns.push({
title: t('倍率'),
title: t('分组倍率'),
dataIndex: 'ratio',
render: (text) => (
<Tag color='white' size='small' shape='circle'>
<Tag color='blue' size='small' shape='circle'>
{text}x
</Tag>
),
});
}
// 添加计费类型列
columns.push({
title: t('计费类型'),
dataIndex: 'billingType',
@@ -115,6 +118,7 @@ const ModelPricingTable = ({
let color = 'white';
if (text === t('按量计费')) color = 'violet';
else if (text === t('按次计费')) color = 'teal';
else if (text === t('动态计费')) color = 'amber';
return (
<Tag color={color} size='small' shape='circle'>
{text || '-'}
@@ -126,18 +130,27 @@ const ModelPricingTable = ({
columns.push({
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
dataIndex: 'priceItems',
render: (items) => (
<div className='space-y-1'>
{items.map((item) => (
<div key={item.key}>
<div className='font-semibold text-orange-600'>
{item.label} {item.value}
render: (items) => {
if (items.length === 1 && items[0].isDynamic) {
return (
<Text type='tertiary' size='small'>
{t('见上方动态计费详情')}
</Text>
);
}
return (
<div className='space-y-1'>
{items.map((item) => (
<div key={item.key}>
<div className='font-semibold text-orange-600'>
{item.label} {item.value}
</div>
<div className='text-xs text-gray-500'>{item.suffix}</div>
</div>
<div className='text-xs text-gray-500'>{item.suffix}</div>
</div>
))}
</div>
),
))}
</div>
);
},
});
return (
@@ -38,6 +38,7 @@ import {
stringToColor,
calculateModelPrice,
formatPriceInfo,
formatDynamicPriceSummary,
getLobeHubIcon,
} from '../../../../../helpers';
import PricingCardSkeleton from './PricingCardSkeleton';
@@ -267,7 +268,11 @@ const PricingCardView = ({
{model.model_name}
</h3>
<div className='flex flex-col gap-1 text-xs mt-1'>
{formatPriceInfo(priceData, t, siteDisplayType)}
{priceData.isDynamicPricing ? (
formatDynamicPriceSummary(priceData.billingExpr, t)
) : (
formatPriceInfo(priceData, t, siteDisplayType)
)}
</div>
</div>
</div>
@@ -33,6 +33,7 @@ import {
getLogOther,
renderModelTag,
renderModelPriceSimple,
renderTieredModelPriceSimple,
} from '../../../helpers';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import { Route, Sparkles } from 'lucide-react';
@@ -377,43 +378,6 @@ function renderCompactDetailSummary(summarySegments) {
);
}
function buildTieredBillingSegments(other, t) {
const segments = [
{ text: `${t('阶梯计费')}`, tone: 'primary' },
];
if (other.matched_tier) {
segments.push({
text: `${t('命中档位')}: ${other.matched_tier}`,
tone: 'secondary',
});
}
const groupRatio = other.group_ratio;
if (groupRatio !== undefined && groupRatio !== null) {
segments.push({
text: `${t('分组')} ${formatRatio(groupRatio)}x`,
tone: 'secondary',
});
}
if (other.crossed_tier) {
segments.push({
text: `${t('跨阶梯')}: ${t('是')}`,
tone: 'secondary',
});
}
if (other.actual_quota_after_group !== undefined) {
segments.push({
text: `${t('实际额度')}: ${other.actual_quota_after_group}`,
tone: 'secondary',
});
}
return { segments };
}
function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
const other = getLogOther(record.other);
@@ -451,52 +415,16 @@ function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
};
}
const summaryOpts = { ...other, displayMode: billingDisplayMode, outputMode: 'segments' };
if (other?.billing_mode === 'tiered_expr') {
return buildTieredBillingSegments(other, t);
return { segments: renderTieredModelPriceSimple(summaryOpts) };
}
return {
segments: other?.claude
? renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'claude',
billingDisplayMode,
'segments',
)
: renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
0,
1.0,
0,
1.0,
0,
1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'openai',
billingDisplayMode,
'segments',
),
? renderModelPriceSimple({ ...summaryOpts, provider: 'claude' })
: renderModelPriceSimple({ ...summaryOpts, provider: 'openai' }),
};
}