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-16 18:57:14 +08:00
parent 91ed4e196a
commit f0589cc478
23 changed files with 1237 additions and 695 deletions
+289 -117
View File
@@ -1620,37 +1620,38 @@ function renderPriceSimpleCore({
return result;
}
export function renderModelPrice(
inputTokens,
completionTokens,
modelRatio,
modelPrice = -1,
completionRatio,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
image = false,
imageRatio = 1.0,
imageOutputTokens = 0,
webSearch = false,
webSearchCallCount = 0,
webSearchPrice = 0,
fileSearch = false,
fileSearchCallCount = 0,
fileSearchPrice = 0,
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
imageGenerationCall = false,
imageGenerationCallPrice = 0,
displayMode = 'price',
) {
export function renderModelPrice(opts) {
const {
prompt_tokens: inputTokens = 0,
completion_tokens: completionTokens = 0,
model_ratio: modelRatio = 0,
model_price: modelPrice = -1,
completion_ratio: completionRatio,
group_ratio: _groupRatio,
user_group_ratio,
cache_tokens: cacheTokens = 0,
cache_ratio: cacheRatio = 1.0,
image = false,
image_ratio: imageRatio = 1.0,
image_output: imageOutputTokens = 0,
web_search: webSearch = false,
web_search_call_count: webSearchCallCount = 0,
web_search_price: webSearchPrice = 0,
file_search: fileSearch = false,
file_search_call_count: fileSearchCallCount = 0,
file_search_price: fileSearchPrice = 0,
audio_input_seperate_price: audioInputSeperatePrice = false,
audio_input_token_count: audioInputTokens = 0,
audio_input_price: audioInputPrice = 0,
image_generation_call: imageGenerationCall = false,
image_generation_call_price: imageGenerationCallPrice = 0,
displayMode = 'price',
} = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
_groupRatio,
user_group_ratio,
);
groupRatio = effectiveGroupRatio;
let groupRatio = effectiveGroupRatio;
const { symbol, rate } = getCurrencyConfig();
@@ -2078,21 +2079,22 @@ export function renderModelPrice(
]);
}
export function renderLogContent(
modelRatio,
completionRatio,
modelPrice = -1,
groupRatio,
user_group_ratio,
cacheRatio = 1.0,
image = false,
imageRatio = 1.0,
webSearch = false,
webSearchCallCount = 0,
fileSearch = false,
fileSearchCallCount = 0,
displayMode = 'price',
) {
export function renderLogContent(opts) {
const {
model_ratio: modelRatio,
completion_ratio: completionRatio,
model_price: modelPrice = -1,
group_ratio: groupRatio,
user_group_ratio,
cache_ratio: cacheRatio = 1.0,
image = false,
image_ratio: imageRatio = 1.0,
web_search: webSearch = false,
web_search_call_count: webSearchCallCount = 0,
file_search: fileSearch = false,
file_search_call_count: fileSearchCallCount = 0,
displayMode = 'price',
} = opts;
const {
ratio,
label: ratioLabel,
@@ -2208,26 +2210,193 @@ export function renderLogContent(
}
}
export function renderModelPriceSimple(
modelRatio,
modelPrice = -1,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
cacheCreationTokens5m = 0,
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
image = false,
imageRatio = 1.0,
isSystemPromptOverride = false,
provider = 'openai',
displayMode = 'price',
outputMode = 'text',
) {
function parseTiersFromExpr(exprStr) {
if (!exprStr) return [];
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 tierRe = new RegExp(`tier\\("([^"]*)",\\s*${bodyPat}\\)`, 'g');
const tiers = [];
let m;
while ((m = tierRe.exec(exprStr)) !== null) {
tiers.push({
label: m[1],
inputPrice: Number(m[2]) * 2,
outputPrice: Number(m[3]) * 2,
cacheReadPrice: m[4] ? Number(m[4]) * 2 : 0,
cacheCreatePrice: m[5] ? Number(m[5]) * 2 : 0,
cacheCreate1hPrice: m[6] ? Number(m[6]) * 2 : 0,
});
}
return tiers;
} catch {
return [];
}
}
export function renderTieredModelPrice(opts) {
const {
prompt_tokens: inputTokens = 0,
completion_tokens: completionTokens = 0,
expr_b64: exprB64,
matched_tier: matchedTier,
group_ratio: groupRatio,
cache_tokens: cacheTokens = 0,
cache_creation_tokens: cacheCreationTokens = 0,
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
} = opts;
let exprStr = '';
try { exprStr = atob(exprB64); } catch { /* ignore */ }
const tiers = parseTiersFromExpr(exprStr);
if (tiers.length === 0) {
return i18next.t('阶梯计费(表达式解析失败)');
}
const tier = tiers.find((t) => t.label === matchedTier) || tiers[0];
const { symbol, rate } = getCurrencyConfig();
const gr = groupRatio || 1;
const inputCost = (inputTokens / 1000000) * tier.inputPrice;
const outputCost = (completionTokens / 1000000) * tier.outputPrice;
const cacheReadCost = (cacheTokens / 1000000) * tier.cacheReadPrice;
const hasSplitCacheCreation = cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
let cacheCreateCost = 0;
if (hasSplitCacheCreation) {
cacheCreateCost = (cacheCreationTokens5m / 1000000) * tier.cacheCreatePrice
+ (cacheCreationTokens1h / 1000000) * tier.cacheCreate1hPrice;
} else if (cacheCreationTokens > 0) {
cacheCreateCost = (cacheCreationTokens / 1000000) * tier.cacheCreatePrice;
}
const totalBeforeGroup = inputCost + outputCost + cacheReadCost + cacheCreateCost;
const total = totalBeforeGroup * gr;
const lines = [
buildBillingText('命中档位:{{tier}}', { tier: matchedTier || tier.label }),
buildBillingPriceText('输入价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.inputPrice, rate }),
buildBillingPriceText('输出价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.outputPrice, rate }),
cacheTokens > 0 && tier.cacheReadPrice > 0
? buildBillingPriceText('缓存读取价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.cacheReadPrice, rate })
: null,
hasSplitCacheCreation && cacheCreationTokens5m > 0 && tier.cacheCreatePrice > 0
? buildBillingPriceText('5m缓存创建价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.cacheCreatePrice, rate })
: null,
hasSplitCacheCreation && cacheCreationTokens1h > 0 && tier.cacheCreate1hPrice > 0
? buildBillingPriceText('1h缓存创建价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.cacheCreate1hPrice, rate })
: null,
!hasSplitCacheCreation && cacheCreationTokens > 0 && tier.cacheCreatePrice > 0
? buildBillingPriceText('缓存创建价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.cacheCreatePrice, rate })
: null,
buildBillingText(
'(输入 {{input}} tokens / 1M tokens * {{symbol}}{{inputPrice}} + 输出 {{output}} tokens / 1M tokens * {{symbol}}{{outputPrice}}) * 分组倍率 {{ratio}} = {{symbol}}{{total}}',
{
input: inputTokens, output: completionTokens, symbol,
inputPrice: formatBillingDisplayPrice(tier.inputPrice, rate),
outputPrice: formatBillingDisplayPrice(tier.outputPrice, rate),
ratio: gr, total: formatBillingDisplayPrice(total, rate),
},
),
];
return renderBillingArticle(lines);
}
export function renderTieredModelPriceSimple(opts) {
const {
expr_b64: exprB64,
matched_tier: matchedTier,
group_ratio: groupRatio,
user_group_ratio,
cache_tokens: cacheTokens = 0,
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
cache_creation_tokens: cacheCreationTokens = 0,
displayMode = 'price',
outputMode = 'segments',
} = opts;
let exprStr = '';
try { exprStr = atob(exprB64); } catch { /* ignore */ }
const tiers = parseTiersFromExpr(exprStr);
const tier = tiers.find((t) => t.label === matchedTier) || tiers[0];
if (outputMode === 'segments') {
const segments = [
{
tone: 'primary',
text: getGroupRatioText(groupRatio, user_group_ratio),
},
];
if (tier && isPriceDisplayMode(displayMode)) {
segments.push({
tone: 'secondary',
text: i18next.t('输入 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(tier.inputPrice),
}),
});
if (cacheTokens > 0 && tier.cacheReadPrice > 0) {
segments.push({
tone: 'secondary',
text: i18next.t('缓存读 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(tier.cacheReadPrice),
}),
});
}
const hasSplitCacheCreation = cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
if (hasSplitCacheCreation && cacheCreationTokens5m > 0 && tier.cacheCreatePrice > 0) {
segments.push({
tone: 'secondary',
text: i18next.t('5m缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(tier.cacheCreatePrice),
}),
});
}
if (hasSplitCacheCreation && cacheCreationTokens1h > 0 && tier.cacheCreate1hPrice > 0) {
segments.push({
tone: 'secondary',
text: i18next.t('1h缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(tier.cacheCreate1hPrice),
}),
});
}
if (!hasSplitCacheCreation && cacheCreationTokens > 0 && tier.cacheCreatePrice > 0) {
segments.push({
tone: 'secondary',
text: i18next.t('缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(tier.cacheCreatePrice),
}),
});
}
}
return segments;
}
return [];
}
export function renderModelPriceSimple(opts) {
const {
model_ratio: modelRatio,
model_price: modelPrice = -1,
group_ratio: groupRatio,
user_group_ratio,
cache_tokens: cacheTokens = 0,
cache_ratio: cacheRatio = 1.0,
cache_creation_tokens: cacheCreationTokens = 0,
cache_creation_ratio: cacheCreationRatio = 1.0,
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
image = false,
image_ratio: imageRatio = 1.0,
is_system_prompt_overwritten: isSystemPromptOverride = false,
provider = 'openai',
displayMode = 'price',
outputMode = 'text',
} = opts;
return renderPriceSimpleCore({
modelRatio,
modelPrice,
@@ -2249,27 +2418,28 @@ export function renderModelPriceSimple(
});
}
export function renderAudioModelPrice(
inputTokens,
completionTokens,
modelRatio,
modelPrice = -1,
completionRatio,
audioInputTokens,
audioCompletionTokens,
audioRatio,
audioCompletionRatio,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
displayMode = 'price',
) {
export function renderAudioModelPrice(opts) {
const {
prompt_tokens: inputTokens = 0,
completion_tokens: completionTokens = 0,
model_ratio: modelRatio = 0,
model_price: modelPrice = -1,
completion_ratio: completionRatio,
audio_input: audioInputTokens = 0,
audio_output: audioCompletionTokens = 0,
audio_ratio: audioRatio,
audio_completion_ratio: audioCompletionRatio,
group_ratio: _groupRatio,
user_group_ratio,
cache_tokens: cacheTokens = 0,
cache_ratio: cacheRatio = 1.0,
displayMode = 'price',
} = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
_groupRatio,
user_group_ratio,
);
groupRatio = effectiveGroupRatio;
let groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
@@ -2535,29 +2705,30 @@ export function renderQuotaWithPrompt(quota, digits) {
return '';
}
export function renderClaudeModelPrice(
inputTokens,
completionTokens,
modelRatio,
modelPrice = -1,
completionRatio,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
cacheCreationTokens5m = 0,
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
displayMode = 'price',
) {
export function renderClaudeModelPrice(opts) {
const {
prompt_tokens: inputTokens = 0,
completion_tokens: completionTokens = 0,
model_ratio: modelRatio = 0,
model_price: modelPrice = -1,
completion_ratio: completionRatio,
group_ratio: _groupRatio,
user_group_ratio,
cache_tokens: cacheTokens = 0,
cache_ratio: cacheRatio = 1.0,
cache_creation_tokens: cacheCreationTokens = 0,
cache_creation_ratio: cacheCreationRatio = 1.0,
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
displayMode = 'price',
} = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
_groupRatio,
user_group_ratio,
);
groupRatio = effectiveGroupRatio;
let groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
@@ -2944,25 +3115,26 @@ export function renderClaudeModelPrice(
]);
}
export function renderClaudeLogContent(
modelRatio,
completionRatio,
modelPrice = -1,
groupRatio,
user_group_ratio,
cacheRatio = 1.0,
cacheCreationRatio = 1.0,
cacheCreationTokens5m = 0,
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
displayMode = 'price',
) {
export function renderClaudeLogContent(opts) {
const {
model_ratio: modelRatio,
completion_ratio: completionRatio,
model_price: modelPrice = -1,
group_ratio: _groupRatio,
user_group_ratio,
cache_ratio: cacheRatio = 1.0,
cache_creation_ratio: cacheCreationRatio = 1.0,
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
displayMode = 'price',
} = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
_groupRatio,
user_group_ratio,
);
groupRatio = effectiveGroupRatio;
let groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
+100 -1
View File
@@ -645,7 +645,17 @@ export const calculateModelPrice = ({
}
}
// 2. 根据计费类型计算价格
// 2. 动态计费(tiered_expr
if (record.billing_mode === 'tiered_expr' && record.billing_expr) {
return {
isDynamicPricing: true,
billingExpr: record.billing_expr,
usedGroup,
usedGroupRatio,
};
}
// 3. 根据计费类型计算价格
if (record.quota_type === 0) {
// 按量计费
const isTokensDisplay = quotaDisplayType === 'TOKENS';
@@ -766,6 +776,18 @@ export const getModelPriceItems = (
t,
quotaDisplayType = 'USD',
) => {
if (priceData.isDynamicPricing) {
return [
{
key: 'dynamic',
label: t('动态计费'),
value: '',
suffix: '',
isDynamic: true,
},
];
}
if (priceData.isPerToken) {
if (quotaDisplayType === 'TOKENS' || priceData.isTokensDisplay) {
return [
@@ -874,6 +896,83 @@ export const getModelPriceItems = (
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
};
// 格式化动态计费摘要(用于卡片视图,与 formatPriceInfo 风格统一)
export const formatDynamicPriceSummary = (billingExpr, t) => {
if (!billingExpr) return <span style={{ color: 'var(--semi-color-text-1)' }}>{t('动态计费')}</span>;
const tierMatches = billingExpr.match(/tier\(/g) || [];
const tierCount = tierMatches.length;
const firstTierMatch = billingExpr.match(
/tier\("[^"]*",\s*p\s*\*\s*([\d.eE+-]+)\s*\+\s*c\s*\*\s*([\d.eE+-]+)(?:\s*\+\s*cr\s*\*\s*([\d.eE+-]+))?(?:\s*\+\s*cc\s*\*\s*([\d.eE+-]+))?/,
);
const hasTimeCondition = /\b(?:hour|weekday|month|day)\(/.test(billingExpr);
const hasRequestCondition = /\b(?:param|header)\(/.test(billingExpr);
const tags = [];
if (tierCount > 1) tags.push(`${tierCount}${t('档')}`);
if (hasTimeCondition) tags.push(t('含时间条件'));
if (hasRequestCondition) tags.push(t('含请求条件'));
const unitSuffix = ' / 1M Tokens';
const lineStyle = { color: 'var(--semi-color-text-1)' };
return (
<>
{firstTierMatch && (
<>
<span style={lineStyle}>
{t('输入价格')} ${(Number(firstTierMatch[1]) * 2).toFixed(4)}{unitSuffix}
</span>
<span style={lineStyle}>
{t('输出价格')} ${(Number(firstTierMatch[2]) * 2).toFixed(4)}{unitSuffix}
</span>
{firstTierMatch[3] && (
<span style={lineStyle}>
{t('缓存读取价格')} ${(Number(firstTierMatch[3]) * 2).toFixed(4)}{unitSuffix}
</span>
)}
{firstTierMatch[4] && (
<span style={lineStyle}>
{t('缓存创建价格')} ${(Number(firstTierMatch[4]) * 2).toFixed(4)}{unitSuffix}
</span>
)}
</>
)}
<span style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<span
style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: 4,
fontSize: 11,
background: 'var(--semi-color-warning-light-default)',
color: 'var(--semi-color-warning)',
}}
>
{t('动态计费')}
</span>
{tags.map((tag) => (
<span
key={tag}
style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: 4,
fontSize: 11,
background: 'var(--semi-color-fill-1)',
color: 'var(--semi-color-text-2)',
}}
>
{tag}
</span>
))}
</span>
</>
);
};
// 格式化价格信息(用于卡片视图)
export const formatPriceInfo = (priceData, t, quotaDisplayType = 'USD') => {
const items = getModelPriceItems(priceData, t, quotaDisplayType);