Commit 938dc952 by wans10 Committed by GitHub

fix(web): 修复阶梯计费 Base64 解码失败与标签不匹配导致的显示错误 (#4530)

* fix(web): 修复阶梯计费表达式解析与匹配逻辑

- 优化 Base64 解码逻辑:引入 UTF-8 感知的解码方法(使用 TextDecoder/Uint8Array),替换原有的简单 `atob`,修复包含非拉丁字符时解码失败的问题。
- 增强阶梯标签匹配机制:新增标签规范化处理(移除空格、统一大小写、转换 `<`/`≤`/`<=` 等符号),确保日志记录中的标签能够与配置中的标签准确匹配。
- 将上述修复同步应用于 default 和 classic 两套前端主题。

* refactor(web): 完善 Base64 解码函数的类型声明

- 根据 CodeRabbitAI 的代码审查建议,将 `decodeBillingExprB64` 方法中 `Array.prototype.map` 回调函数的参数类型由 `any` 替换为更精确的 `number`。
- 提高了代码的类型安全性与可读性。

* fix(web): 修复动态价格明细表中阶梯高亮未能正确匹配的问题

- 在 default 主题的 `DynamicPricingBreakdown` 组件中,引入 `normalizeTierLabel` 函数。
- 替换原有对 `matchedTierLabel` 的严格相等判定,确保在包含全半角符号(如 `≤`/`<=`)或存在空格等格式不一致的场景下,日志详情中的表格依然能准确高亮(Matched)当前命中的对应计费阶梯。

* refactor(web): 移除阶梯计费标签不匹配时的强制兜底逻辑

- 在 default 和 classic 主题中,修改 `resolveMatchedTier` 和相关的阶梯匹配方法,当日志中 `matched_tier` 无法与表达式中的阶梯标签严格对应时,直接返回 `null` 而不再默认退化展示第一阶梯(`tiers[0]`)的价格。
- 遵循“数据准确性优先”的计费展示准则,防止因匹配失败而向用户展示猜测出的单价,避免产生账单误导及客诉风险。
- 在 Classic 主题账单卡片中,对于无法匹配的异常账单明确展示“未匹配到对应阶梯”的提示。

* fix(web): 修复阶梯计费标签正则匹配的短路问题

- 根据 CodeRabbitAI 的代码审查反馈,修正了 `normalizeLabel`(以及 `normalizeTierLabel`)函数中的正则表达式分支顺序。
- 将原本的 `/<|≤|<=/` 调整为 `/<=|≤|</`,以修复 JavaScript 正则引擎从左到右匹配时,会将 `<=` 中的 `<` 优先短路匹配,导致残留 `=` 号的问题。
- 确保了双字符操作符(如 `<=`、`>=`)现在能够被正确完整地替换为单字符(`<`、`>`),保证了计费阶梯日志匹配的准确性。

* fix(web): 完善阶梯计费未匹配展示

---------

Co-authored-by: CaIon <i@caion.me>
parent 5114ad06
...@@ -178,19 +178,19 @@ export const getModelCategories = (() => { ...@@ -178,19 +178,19 @@ export const getModelCategories = (() => {
label: 'OpenAI', label: 'OpenAI',
icon: <OpenAI />, icon: <OpenAI />,
filter: (model) => filter: (model) =>
model.model_name.toLowerCase().includes('gpt') || model.model_name.toLowerCase().includes('gpt') ||
model.model_name.toLowerCase().includes('dall-e') || model.model_name.toLowerCase().includes('dall-e') ||
model.model_name.toLowerCase().includes('whisper') || model.model_name.toLowerCase().includes('whisper') ||
model.model_name.toLowerCase().includes('tts-1') || model.model_name.toLowerCase().includes('tts-1') ||
model.model_name.toLowerCase().includes('text-embedding-3') || model.model_name.toLowerCase().includes('text-embedding-3') ||
model.model_name.toLowerCase().includes('text-moderation') || model.model_name.toLowerCase().includes('text-moderation') ||
model.model_name.toLowerCase().includes('babbage') || model.model_name.toLowerCase().includes('babbage') ||
model.model_name.toLowerCase().includes('davinci') || model.model_name.toLowerCase().includes('davinci') ||
model.model_name.toLowerCase().includes('curie') || model.model_name.toLowerCase().includes('curie') ||
model.model_name.toLowerCase().includes('ada') || model.model_name.toLowerCase().includes('ada') ||
model.model_name.toLowerCase().includes('o1') || model.model_name.toLowerCase().includes('o1') ||
model.model_name.toLowerCase().includes('o3') || model.model_name.toLowerCase().includes('o3') ||
model.model_name.toLowerCase().includes('o4'), model.model_name.toLowerCase().includes('o4'),
}, },
anthropic: { anthropic: {
label: 'Anthropic', label: 'Anthropic',
...@@ -201,30 +201,30 @@ export const getModelCategories = (() => { ...@@ -201,30 +201,30 @@ export const getModelCategories = (() => {
label: 'Gemini', label: 'Gemini',
icon: <Gemini.Color />, icon: <Gemini.Color />,
filter: (model) => filter: (model) =>
model.model_name.toLowerCase().includes('gemini') || model.model_name.toLowerCase().includes('gemini') ||
model.model_name.toLowerCase().includes('gemma') || model.model_name.toLowerCase().includes('gemma') ||
model.model_name.toLowerCase().includes('learnlm') || model.model_name.toLowerCase().includes('learnlm') ||
model.model_name.toLowerCase().startsWith('embedding-') || model.model_name.toLowerCase().startsWith('embedding-') ||
model.model_name.toLowerCase().includes('text-embedding-004') || model.model_name.toLowerCase().includes('text-embedding-004') ||
model.model_name.toLowerCase().includes('imagen-4') || model.model_name.toLowerCase().includes('imagen-4') ||
model.model_name.toLowerCase().includes('veo-') || model.model_name.toLowerCase().includes('veo-') ||
model.model_name.toLowerCase().includes('aqa'), model.model_name.toLowerCase().includes('aqa'),
}, },
moonshot: { moonshot: {
label: 'Moonshot', label: 'Moonshot',
icon: <Moonshot />, icon: <Moonshot />,
filter: (model) => filter: (model) =>
model.model_name.toLowerCase().includes('moonshot') || model.model_name.toLowerCase().includes('moonshot') ||
model.model_name.toLowerCase().includes('kimi'), model.model_name.toLowerCase().includes('kimi'),
}, },
zhipu: { zhipu: {
label: t('智谱'), label: t('智谱'),
icon: <Zhipu.Color />, icon: <Zhipu.Color />,
filter: (model) => filter: (model) =>
model.model_name.toLowerCase().includes('chatglm') || model.model_name.toLowerCase().includes('chatglm') ||
model.model_name.toLowerCase().includes('glm-') || model.model_name.toLowerCase().includes('glm-') ||
model.model_name.toLowerCase().includes('cogview') || model.model_name.toLowerCase().includes('cogview') ||
model.model_name.toLowerCase().includes('cogvideo'), model.model_name.toLowerCase().includes('cogvideo'),
}, },
qwen: { qwen: {
label: t('通义千问'), label: t('通义千问'),
...@@ -240,8 +240,8 @@ export const getModelCategories = (() => { ...@@ -240,8 +240,8 @@ export const getModelCategories = (() => {
label: 'MiniMax', label: 'MiniMax',
icon: <Minimax.Color />, icon: <Minimax.Color />,
filter: (model) => filter: (model) =>
model.model_name.toLowerCase().includes('abab') || model.model_name.toLowerCase().includes('abab') ||
model.model_name.toLowerCase().includes('minimax'), model.model_name.toLowerCase().includes('minimax'),
}, },
baidu: { baidu: {
label: t('文心一言'), label: t('文心一言'),
...@@ -267,9 +267,9 @@ export const getModelCategories = (() => { ...@@ -267,9 +267,9 @@ export const getModelCategories = (() => {
label: 'Cohere', label: 'Cohere',
icon: <Cohere.Color />, icon: <Cohere.Color />,
filter: (model) => filter: (model) =>
model.model_name.toLowerCase().includes('command') || model.model_name.toLowerCase().includes('command') ||
model.model_name.toLowerCase().includes('c4ai-') || model.model_name.toLowerCase().includes('c4ai-') ||
model.model_name.toLowerCase().includes('embed-'), model.model_name.toLowerCase().includes('embed-'),
}, },
cloudflare: { cloudflare: {
label: 'Cloudflare', label: 'Cloudflare',
...@@ -290,11 +290,11 @@ export const getModelCategories = (() => { ...@@ -290,11 +290,11 @@ export const getModelCategories = (() => {
label: 'Mistral AI', label: 'Mistral AI',
icon: <Mistral.Color />, icon: <Mistral.Color />,
filter: (model) => filter: (model) =>
model.model_name.toLowerCase().includes('mistral') || model.model_name.toLowerCase().includes('mistral') ||
model.model_name.toLowerCase().includes('codestral') || model.model_name.toLowerCase().includes('codestral') ||
model.model_name.toLowerCase().includes('pixtral') || model.model_name.toLowerCase().includes('pixtral') ||
model.model_name.toLowerCase().includes('voxtral') || model.model_name.toLowerCase().includes('voxtral') ||
model.model_name.toLowerCase().includes('magistral'), model.model_name.toLowerCase().includes('magistral'),
}, },
xai: { xai: {
label: 'xAI', label: 'xAI',
...@@ -450,8 +450,8 @@ export function getLobeHubIcon(iconName, size = 14) { ...@@ -450,8 +450,8 @@ export function getLobeHubIcon(iconName, size = 14) {
// 失败兜底 // 失败兜底
if ( if (
!IconComponent || !IconComponent ||
(typeof IconComponent !== 'function' && typeof IconComponent !== 'object') (typeof IconComponent !== 'function' && typeof IconComponent !== 'object')
) { ) {
const firstLetter = String(iconName).charAt(0).toUpperCase(); const firstLetter = String(iconName).charAt(0).toUpperCase();
return <Avatar size='extra-extra-small'>{firstLetter}</Avatar>; return <Avatar size='extra-extra-small'>{firstLetter}</Avatar>;
...@@ -469,8 +469,8 @@ export function getLobeHubIcon(iconName, size = 14) { ...@@ -469,8 +469,8 @@ export function getLobeHubIcon(iconName, size = 14) {
} }
// 去除引号 // 去除引号
if ( if (
(v.startsWith('"') && v.endsWith('"')) || (v.startsWith('"') && v.endsWith('"')) ||
(v.startsWith("'") && v.endsWith("'")) (v.startsWith("'") && v.endsWith("'"))
) { ) {
return v.slice(1, -1); return v.slice(1, -1);
} }
...@@ -541,11 +541,11 @@ function isSimpleEmoji(value) { ...@@ -541,11 +541,11 @@ function isSimpleEmoji(value) {
function normalizeOAuthIconKey(raw) { function normalizeOAuthIconKey(raw) {
return raw return raw
.trim() .trim()
.toLowerCase() .toLowerCase()
.replace(/^ri:/, '') .replace(/^ri:/, '')
.replace(/^react-icons:/, '') .replace(/^react-icons:/, '')
.replace(/^si:/, ''); .replace(/^si:/, '');
} }
/** /**
...@@ -566,28 +566,28 @@ export function getOAuthProviderIcon(iconName, size = 20) { ...@@ -566,28 +566,28 @@ export function getOAuthProviderIcon(iconName, size = 20) {
if (isHttpUrl(raw)) { if (isHttpUrl(raw)) {
return ( return (
<img <img
src={raw} src={raw}
alt='provider icon' alt='provider icon'
width={iconSize} width={iconSize}
height={iconSize} height={iconSize}
style={{ borderRadius: 4, objectFit: 'cover' }} style={{ borderRadius: 4, objectFit: 'cover' }}
/> />
); );
} }
if (isSimpleEmoji(raw)) { if (isSimpleEmoji(raw)) {
return ( return (
<span <span
style={{ style={{
width: iconSize, width: iconSize,
height: iconSize, height: iconSize,
lineHeight: `${iconSize}px`, lineHeight: `${iconSize}px`,
textAlign: 'center', textAlign: 'center',
display: 'inline-block', display: 'inline-block',
fontSize: Math.max(Math.floor(iconSize * 0.8), 14), fontSize: Math.max(Math.floor(iconSize * 0.8), 14),
}} }}
> >
{raw} {raw}
</span> </span>
); );
...@@ -600,7 +600,7 @@ export function getOAuthProviderIcon(iconName, size = 20) { ...@@ -600,7 +600,7 @@ export function getOAuthProviderIcon(iconName, size = 20) {
} }
return ( return (
<Avatar size='extra-extra-small'>{raw.charAt(0).toUpperCase()}</Avatar> <Avatar size='extra-extra-small'>{raw.charAt(0).toUpperCase()}</Avatar>
); );
} }
...@@ -757,16 +757,16 @@ export function renderModelTag(modelName, options = {}) { ...@@ -757,16 +757,16 @@ export function renderModelTag(modelName, options = {}) {
} }
return ( return (
<Tag <Tag
color={color || stringToColor(modelName)} color={color || stringToColor(modelName)}
prefixIcon={icon} prefixIcon={icon}
suffixIcon={suffixIcon} suffixIcon={suffixIcon}
size={size} size={size}
shape={shape} shape={shape}
onClick={onClick} onClick={onClick}
> >
{modelName} {modelName}
</Tag> </Tag>
); );
} }
...@@ -785,9 +785,9 @@ export function renderText(text, limit) { ...@@ -785,9 +785,9 @@ export function renderText(text, limit) {
export function renderGroup(group) { export function renderGroup(group) {
if (group === '') { if (group === '') {
return ( return (
<Tag key='default' color='white' shape='circle'> <Tag key='default' color='white' shape='circle'>
{i18next.t('用户分组')} {i18next.t('用户分组')}
</Tag> </Tag>
); );
} }
...@@ -801,26 +801,26 @@ export function renderGroup(group) { ...@@ -801,26 +801,26 @@ export function renderGroup(group) {
const groups = group.split(',').sort(); const groups = group.split(',').sort();
return ( return (
<span key={group}> <span key={group}>
{groups.map((group) => ( {groups.map((group) => (
<Tag <Tag
color={tagColors[group] || stringToColor(group)} color={tagColors[group] || stringToColor(group)}
key={group} key={group}
shape='circle' shape='circle'
onClick={async (event) => { onClick={async (event) => {
event.stopPropagation(); event.stopPropagation();
if (await copy(group)) { if (await copy(group)) {
showSuccess(i18next.t('已复制:') + group); showSuccess(i18next.t('已复制:') + group);
} else { } else {
Modal.error({ Modal.error({
title: i18next.t('无法复制到剪贴板,请手动复制'), title: i18next.t('无法复制到剪贴板,请手动复制'),
content: group, content: group,
}); });
} }
}} }}
> >
{group} {group}
</Tag> </Tag>
))} ))}
</span> </span>
); );
...@@ -836,20 +836,20 @@ export function renderRatio(ratio) { ...@@ -836,20 +836,20 @@ export function renderRatio(ratio) {
color = 'blue'; color = 'blue';
} }
return ( return (
<Tag color={color}> <Tag color={color}>
{ratio}x {i18next.t('倍率')} {ratio}x {i18next.t('倍率')}
</Tag> </Tag>
); );
} }
const measureTextWidth = ( const measureTextWidth = (
text, text,
style = { style = {
fontSize: '14px', fontSize: '14px',
fontFamily: fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}, },
containerWidth, containerWidth,
) => { ) => {
const span = document.createElement('span'); const span = document.createElement('span');
...@@ -871,7 +871,7 @@ const measureTextWidth = ( ...@@ -871,7 +871,7 @@ const measureTextWidth = (
export function truncateText(text, maxWidth = 200) { export function truncateText(text, maxWidth = 200) {
const isMobileScreen = window.matchMedia( const isMobileScreen = window.matchMedia(
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`, `(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
).matches; ).matches;
if (!isMobileScreen) { if (!isMobileScreen) {
return text; return text;
...@@ -910,8 +910,8 @@ export function truncateText(text, maxWidth = 200) { ...@@ -910,8 +910,8 @@ export function truncateText(text, maxWidth = 200) {
return result; return result;
} catch (error) { } catch (error) {
console.warn( console.warn(
'Text measurement failed, falling back to character count', 'Text measurement failed, falling back to character count',
error, error,
); );
if (text.length > 20) { if (text.length > 20) {
return text.slice(0, 17) + '...'; return text.slice(0, 17) + '...';
...@@ -965,21 +965,21 @@ export const renderGroupOption = (item) => { ...@@ -965,21 +965,21 @@ export const renderGroupOption = (item) => {
}; };
return ( return (
<div <div
style={baseStyle} style={baseStyle}
onClick={handleClick} onClick={handleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<Typography.Text strong type={disabled ? 'tertiary' : undefined}> <Typography.Text strong type={disabled ? 'tertiary' : undefined}>
{value} {value}
</Typography.Text> </Typography.Text>
<Typography.Text type='secondary' size='small'> <Typography.Text type='secondary' size='small'>
{label} {label}
</Typography.Text> </Typography.Text>
</div>
{item.ratio && renderRatio(item.ratio)}
</div> </div>
{item.ratio && renderRatio(item.ratio)}
</div>
); );
}; };
...@@ -1076,8 +1076,8 @@ export function renderQuotaWithAmount(amount) { ...@@ -1076,8 +1076,8 @@ export function renderQuotaWithAmount(amount) {
const numericAmount = Number(amount); const numericAmount = Number(amount);
const formattedAmount = Number.isFinite(numericAmount) const formattedAmount = Number.isFinite(numericAmount)
? numericAmount.toFixed(2) ? numericAmount.toFixed(2)
: amount; : amount;
if (quotaDisplayType === 'CNY') { if (quotaDisplayType === 'CNY') {
return '¥' + formattedAmount; return '¥' + formattedAmount;
...@@ -1195,8 +1195,8 @@ function isValidGroupRatio(ratio) { ...@@ -1195,8 +1195,8 @@ function isValidGroupRatio(ratio) {
function getEffectiveRatio(groupRatio, user_group_ratio) { function getEffectiveRatio(groupRatio, user_group_ratio) {
const useUserGroupRatio = isValidGroupRatio(user_group_ratio); const useUserGroupRatio = isValidGroupRatio(user_group_ratio);
const ratioLabel = useUserGroupRatio const ratioLabel = useUserGroupRatio
? i18next.t('专属倍率') ? i18next.t('专属倍率')
: i18next.t('分组倍率'); : i18next.t('分组倍率');
const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio; const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
return { return {
...@@ -1274,8 +1274,8 @@ function buildBillingText(key, vars) { ...@@ -1274,8 +1274,8 @@ function buildBillingText(key, vars) {
} }
function buildBillingPriceText( function buildBillingPriceText(
key, key,
{ symbol, usdAmount, rate, amountKey = 'price', digits = 6, ...vars }, { symbol, usdAmount, rate, amountKey = 'price', digits = 6, ...vars },
) { ) {
return buildBillingText(key, { return buildBillingText(key, {
symbol, symbol,
...@@ -1292,52 +1292,52 @@ function renderBillingArticle(lines, { showReferenceNote = true } = {}) { ...@@ -1292,52 +1292,52 @@ function renderBillingArticle(lines, { showReferenceNote = true } = {}) {
} }
return ( return (
<article> <article>
{articleLines.map((line, index) => ( {articleLines.map((line, index) => (
<p key={index}>{line}</p> <p key={index}>{line}</p>
))} ))}
</article> </article>
); );
} }
// Shared core for simple price rendering (used by OpenAI-like and Claude-like variants) // Shared core for simple price rendering (used by OpenAI-like and Claude-like variants)
function renderPriceSimpleCore({ function renderPriceSimpleCore({
modelRatio, modelRatio,
modelPrice = -1, modelPrice = -1,
groupRatio, groupRatio,
user_group_ratio, user_group_ratio,
cacheTokens = 0, cacheTokens = 0,
cacheRatio = 1.0, cacheRatio = 1.0,
cacheCreationTokens = 0, cacheCreationTokens = 0,
cacheCreationRatio = 1.0, cacheCreationRatio = 1.0,
cacheCreationTokens5m = 0, cacheCreationTokens5m = 0,
cacheCreationRatio5m = 1.0, cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0, cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0, cacheCreationRatio1h = 1.0,
image = false, image = false,
imageRatio = 1.0, imageRatio = 1.0,
isSystemPromptOverride = false, isSystemPromptOverride = false,
displayMode = 'price', displayMode = 'price',
outputMode = 'text', outputMode = 'text',
}) { }) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio, groupRatio,
user_group_ratio, user_group_ratio,
); );
const finalGroupRatio = effectiveGroupRatio; const finalGroupRatio = effectiveGroupRatio;
const { symbol, rate } = getCurrencyConfig(); const { symbol, rate } = getCurrencyConfig();
const hasSplitCacheCreation = const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const shouldShowLegacyCacheCreation = const shouldShowLegacyCacheCreation =
!hasSplitCacheCreation && cacheCreationTokens !== 0; !hasSplitCacheCreation && cacheCreationTokens !== 0;
const shouldShowCache = cacheTokens !== 0; const shouldShowCache = cacheTokens !== 0;
const shouldShowCacheCreation5m = const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0; hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h = const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0; hasSplitCacheCreation && cacheCreationTokens1h > 0;
if (outputMode === 'segments') { if (outputMode === 'segments') {
const segments = [ const segments = [
...@@ -1351,10 +1351,10 @@ function renderPriceSimpleCore({ ...@@ -1351,10 +1351,10 @@ function renderPriceSimpleCore({
segments.push({ segments.push({
tone: 'secondary', tone: 'secondary',
text: isPriceDisplayMode(displayMode, modelPrice) text: isPriceDisplayMode(displayMode, modelPrice)
? i18next.t('模型价格 {{price}}', { ? i18next.t('模型价格 {{price}}', {
price: formatCompactDisplayPrice(modelPrice), price: formatCompactDisplayPrice(modelPrice),
}) })
: i18next.t('按次'), : i18next.t('按次'),
}); });
} else if (isPriceDisplayMode(displayMode, modelPrice)) { } else if (isPriceDisplayMode(displayMode, modelPrice)) {
segments.push({ segments.push({
...@@ -1378,7 +1378,7 @@ function renderPriceSimpleCore({ ...@@ -1378,7 +1378,7 @@ function renderPriceSimpleCore({
tone: 'secondary', tone: 'secondary',
text: i18next.t('5m缓存创建 {{price}} / 1M tokens', { text: i18next.t('5m缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice( price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio5m, modelRatio * 2.0 * cacheCreationRatio5m,
), ),
}), }),
}); });
...@@ -1388,7 +1388,7 @@ function renderPriceSimpleCore({ ...@@ -1388,7 +1388,7 @@ function renderPriceSimpleCore({
tone: 'secondary', tone: 'secondary',
text: i18next.t('1h缓存创建 {{price}} / 1M tokens', { text: i18next.t('1h缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice( price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio1h, modelRatio * 2.0 * cacheCreationRatio1h,
), ),
}), }),
}); });
...@@ -1398,7 +1398,7 @@ function renderPriceSimpleCore({ ...@@ -1398,7 +1398,7 @@ function renderPriceSimpleCore({
tone: 'secondary', tone: 'secondary',
text: i18next.t('缓存创建 {{price}} / 1M tokens', { text: i18next.t('缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice( price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio, modelRatio * 2.0 * cacheCreationRatio,
), ),
}), }),
}); });
...@@ -1434,11 +1434,11 @@ function renderPriceSimpleCore({ ...@@ -1434,11 +1434,11 @@ function renderPriceSimpleCore({
segments.push({ segments.push({
tone: 'secondary', tone: 'secondary',
text: i18next.t( text: i18next.t(
'缓存创建: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}', '缓存创建: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
{ {
cacheCreationRatio5m: cacheCreationRatio5m, cacheCreationRatio5m: cacheCreationRatio5m,
cacheCreationRatio1h: cacheCreationRatio1h, cacheCreationRatio1h: cacheCreationRatio1h,
}, },
), ),
}); });
} else if (shouldShowCacheCreation5m) { } else if (shouldShowCacheCreation5m) {
...@@ -1508,61 +1508,61 @@ function renderPriceSimpleCore({ ...@@ -1508,61 +1508,61 @@ function renderPriceSimpleCore({
const parts = []; const parts = [];
if (modelPrice !== -1) { if (modelPrice !== -1) {
parts.push( parts.push(
i18next.t('模型价格 {{price}}', { i18next.t('模型价格 {{price}}', {
price: formatCompactDisplayPrice(modelPrice), price: formatCompactDisplayPrice(modelPrice),
}), }),
); );
parts.push(getGroupRatioText(groupRatio, user_group_ratio)); parts.push(getGroupRatioText(groupRatio, user_group_ratio));
return joinBillingSummary(parts); return joinBillingSummary(parts);
} }
parts.push( parts.push(
i18next.t('输入 {{price}} / 1M tokens', { i18next.t('输入 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(modelRatio * 2.0), price: formatCompactDisplayPrice(modelRatio * 2.0),
}), }),
); );
if (shouldShowCache) { if (shouldShowCache) {
parts.push( parts.push(
i18next.t('缓存读 {{price}} / 1M tokens', { i18next.t('缓存读 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(modelRatio * 2.0 * cacheRatio), price: formatCompactDisplayPrice(modelRatio * 2.0 * cacheRatio),
}), }),
); );
} }
if (hasSplitCacheCreation && shouldShowCacheCreation5m) { if (hasSplitCacheCreation && shouldShowCacheCreation5m) {
parts.push( parts.push(
i18next.t('5m缓存创建 {{price}} / 1M tokens', { i18next.t('5m缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice( price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio5m, modelRatio * 2.0 * cacheCreationRatio5m,
), ),
}), }),
); );
} }
if (hasSplitCacheCreation && shouldShowCacheCreation1h) { if (hasSplitCacheCreation && shouldShowCacheCreation1h) {
parts.push( parts.push(
i18next.t('1h缓存创建 {{price}} / 1M tokens', { i18next.t('1h缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice( price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio1h, modelRatio * 2.0 * cacheCreationRatio1h,
), ),
}), }),
); );
} }
if (!hasSplitCacheCreation && shouldShowLegacyCacheCreation) { if (!hasSplitCacheCreation && shouldShowLegacyCacheCreation) {
parts.push( parts.push(
i18next.t('缓存创建 {{price}} / 1M tokens', { i18next.t('缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice( price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio, modelRatio * 2.0 * cacheCreationRatio,
), ),
}), }),
); );
} }
if (image) { if (image) {
parts.push( parts.push(
i18next.t('图片输入 {{price}} / 1M tokens', { i18next.t('图片输入 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(modelRatio * 2.0 * imageRatio), price: formatCompactDisplayPrice(modelRatio * 2.0 * imageRatio),
}), }),
); );
} }
...@@ -1587,9 +1587,9 @@ function renderPriceSimpleCore({ ...@@ -1587,9 +1587,9 @@ function renderPriceSimpleCore({
if (hasSplitCacheCreation) { if (hasSplitCacheCreation) {
if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) { if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) {
parts.push( parts.push(
i18next.t( i18next.t(
'缓存创建: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}', '缓存创建: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
), ),
); );
} else if (shouldShowCacheCreation5m) { } else if (shouldShowCacheCreation5m) {
parts.push(i18next.t('缓存创建: 5m {{cacheCreationRatio5m}}')); parts.push(i18next.t('缓存创建: 5m {{cacheCreationRatio5m}}'));
...@@ -1628,8 +1628,8 @@ function renderPriceSimpleCore({ ...@@ -1628,8 +1628,8 @@ function renderPriceSimpleCore({
export function renderTaskBillingProcess(other, content) { export function renderTaskBillingProcess(other, content) {
if (other?.task_id != null) { if (other?.task_id != null) {
return renderBillingArticle( return renderBillingArticle(
[content].filter(Boolean), [content].filter(Boolean),
{ showReferenceNote: false }, { showReferenceNote: false },
); );
} }
return renderBillingArticle([ return renderBillingArticle([
...@@ -1665,8 +1665,8 @@ export function renderModelPrice(opts) { ...@@ -1665,8 +1665,8 @@ export function renderModelPrice(opts) {
displayMode = 'price', displayMode = 'price',
} = opts; } = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
_groupRatio, _groupRatio,
user_group_ratio, user_group_ratio,
); );
let groupRatio = effectiveGroupRatio; let groupRatio = effectiveGroupRatio;
const completionRatio = _completionRatio ?? 0; const completionRatio = _completionRatio ?? 0;
...@@ -1682,16 +1682,16 @@ export function renderModelPrice(opts) { ...@@ -1682,16 +1682,16 @@ export function renderModelPrice(opts) {
rate, rate,
}), }),
buildBillingPriceText( buildBillingPriceText(
'按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', '按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{ {
symbol, symbol,
usdAmount: modelPrice, usdAmount: modelPrice,
rate, rate,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amountKey: 'price', amountKey: 'price',
total: formatBillingDisplayPrice(modelPrice * groupRatio, rate), total: formatBillingDisplayPrice(modelPrice * groupRatio, rate),
}, },
), ),
]); ]);
} }
...@@ -1701,131 +1701,131 @@ export function renderModelPrice(opts) { ...@@ -1701,131 +1701,131 @@ export function renderModelPrice(opts) {
const cacheRatioPrice = modelRatio * 2.0 * cacheRatio; const cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
const imageRatioPrice = modelRatio * 2.0 * imageRatio; const imageRatioPrice = modelRatio * 2.0 * imageRatio;
let effectiveInputTokens = let effectiveInputTokens =
inputTokens - cacheTokens + cacheTokens * cacheRatio; inputTokens - cacheTokens + cacheTokens * cacheRatio;
if (image && imageOutputTokens > 0) { if (image && imageOutputTokens > 0) {
effectiveInputTokens = effectiveInputTokens =
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio; inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
} }
if (audioInputTokens > 0) { if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens; effectiveInputTokens -= audioInputTokens;
} }
const price = const price =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio + (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(audioInputTokens / 1000000) * audioInputPrice * groupRatio + (audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio + (completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
imageGenerationCallPrice * groupRatio; imageGenerationCallPrice * groupRatio;
let inputDesc = ''; let inputDesc = '';
if (image && imageOutputTokens > 0) { if (image && imageOutputTokens > 0) {
inputDesc = buildBillingPriceText( inputDesc = buildBillingPriceText(
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens / 1M tokens * {{symbol}}{{price}}', '(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens / 1M tokens * {{symbol}}{{price}}',
{ {
nonImageInput: inputTokens - imageOutputTokens, nonImageInput: inputTokens - imageOutputTokens,
imageInput: imageOutputTokens, imageInput: imageOutputTokens,
symbol, symbol,
usdAmount: inputRatioPrice, usdAmount: inputRatioPrice,
rate, rate,
}, },
); );
} else if (cacheTokens > 0) { } else if (cacheTokens > 0) {
inputDesc = buildBillingText( inputDesc = buildBillingText(
'(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}', '(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}',
{ {
nonCacheInput: inputTokens - cacheTokens, nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens, cacheInput: cacheTokens,
symbol, symbol,
price: formatBillingDisplayPrice(inputRatioPrice, rate), price: formatBillingDisplayPrice(inputRatioPrice, rate),
cachePrice: formatBillingDisplayPrice(cacheRatioPrice, rate), cachePrice: formatBillingDisplayPrice(cacheRatioPrice, rate),
}, },
); );
} else if (audioInputSeperatePrice && audioInputTokens > 0) { } else if (audioInputSeperatePrice && audioInputTokens > 0) {
inputDesc = buildBillingText( inputDesc = buildBillingText(
'(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}', '(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}',
{ {
nonAudioInput: inputTokens - audioInputTokens, nonAudioInput: inputTokens - audioInputTokens,
audioInput: audioInputTokens, audioInput: audioInputTokens,
symbol, symbol,
price: formatBillingDisplayPrice(inputRatioPrice, rate), price: formatBillingDisplayPrice(inputRatioPrice, rate),
audioPrice: formatBillingDisplayPrice(audioInputPrice, rate), audioPrice: formatBillingDisplayPrice(audioInputPrice, rate),
}, },
); );
} else { } else {
inputDesc = buildBillingPriceText( inputDesc = buildBillingPriceText(
'(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}', '(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}',
{ {
input: inputTokens, input: inputTokens,
symbol, symbol,
usdAmount: inputRatioPrice, usdAmount: inputRatioPrice,
rate, rate,
}, },
); );
} }
const outputDesc = buildBillingText( const outputDesc = buildBillingText(
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}', '输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
{ {
completion: completionTokens, completion: completionTokens,
symbol, symbol,
compPrice: formatBillingDisplayPrice(completionRatioPrice, rate), compPrice: formatBillingDisplayPrice(completionRatioPrice, rate),
ratio: groupRatio, ratio: groupRatio,
ratioType: ratioLabel, ratioType: ratioLabel,
}, },
); );
const extraServices = [ const extraServices = [
webSearch && webSearchCallCount > 0 webSearch && webSearchCallCount > 0
? buildBillingPriceText( ? buildBillingPriceText(
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}', ' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
{ {
count: webSearchCallCount, count: webSearchCallCount,
symbol, symbol,
usdAmount: webSearchPrice, usdAmount: webSearchPrice,
rate, rate,
ratio: groupRatio, ratio: groupRatio,
ratioType: ratioLabel, ratioType: ratioLabel,
}, },
) )
: '', : '',
fileSearch && fileSearchCallCount > 0 fileSearch && fileSearchCallCount > 0
? buildBillingPriceText( ? buildBillingPriceText(
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}', ' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
{ {
count: fileSearchCallCount, count: fileSearchCallCount,
symbol, symbol,
usdAmount: fileSearchPrice, usdAmount: fileSearchPrice,
rate, rate,
ratio: groupRatio, ratio: groupRatio,
ratioType: ratioLabel, ratioType: ratioLabel,
}, },
) )
: '', : '',
imageGenerationCall && imageGenerationCallPrice > 0 imageGenerationCall && imageGenerationCallPrice > 0
? buildBillingPriceText( ? buildBillingPriceText(
' + 图片生成调用 {{symbol}}{{price}} / 1次 * {{ratioType}} {{ratio}}', ' + 图片生成调用 {{symbol}}{{price}} / 1次 * {{ratioType}} {{ratio}}',
{ {
symbol, symbol,
usdAmount: imageGenerationCallPrice, usdAmount: imageGenerationCallPrice,
rate, rate,
ratio: groupRatio, ratio: groupRatio,
ratioType: ratioLabel, ratioType: ratioLabel,
}, },
) )
: '', : '',
].join(''); ].join('');
const billingLines = [ const billingLines = [
buildBillingPriceText( buildBillingPriceText(
'输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', '输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}',
{ {
symbol, symbol,
usdAmount: inputRatioPrice, usdAmount: inputRatioPrice,
rate, rate,
audioPrice: audioInputSeperatePrice audioPrice: audioInputSeperatePrice
? `,${i18next.t('音频输入价格')} ${symbol}${formatBillingDisplayPrice(audioInputPrice, rate)} / 1M tokens` ? `,${i18next.t('音频输入价格')} ${symbol}${formatBillingDisplayPrice(audioInputPrice, rate)} / 1M tokens`
: '', : '',
}, },
), ),
buildBillingPriceText('输出价格:{{symbol}}{{total}} / 1M tokens', { buildBillingPriceText('输出价格:{{symbol}}{{total}} / 1M tokens', {
symbol, symbol,
...@@ -1834,57 +1834,57 @@ export function renderModelPrice(opts) { ...@@ -1834,57 +1834,57 @@ export function renderModelPrice(opts) {
amountKey: 'total', amountKey: 'total',
}), }),
cacheTokens > 0 cacheTokens > 0
? buildBillingPriceText( ? buildBillingPriceText(
'缓存读取价格:{{symbol}}{{total}} / 1M tokens', '缓存读取价格:{{symbol}}{{total}} / 1M tokens',
{ {
symbol, symbol,
usdAmount: inputRatioPrice * cacheRatio, usdAmount: inputRatioPrice * cacheRatio,
rate, rate,
amountKey: 'total', amountKey: 'total',
}, },
) )
: null, : null,
image && imageOutputTokens > 0 image && imageOutputTokens > 0
? buildBillingPriceText( ? buildBillingPriceText(
'图片输入价格:{{symbol}}{{total}} / 1M tokens', '图片输入价格:{{symbol}}{{total}} / 1M tokens',
{ {
symbol, symbol,
usdAmount: imageRatioPrice, usdAmount: imageRatioPrice,
rate, rate,
amountKey: 'total', amountKey: 'total',
}, },
) )
: null, : null,
webSearch && webSearchCallCount > 0 webSearch && webSearchCallCount > 0
? buildBillingPriceText('Web搜索价格:{{symbol}}{{price}} / 1K 次', { ? buildBillingPriceText('Web搜索价格:{{symbol}}{{price}} / 1K 次', {
symbol, symbol,
usdAmount: webSearchPrice, usdAmount: webSearchPrice,
rate, rate,
}) })
: null, : null,
fileSearch && fileSearchCallCount > 0 fileSearch && fileSearchCallCount > 0
? buildBillingPriceText('文件搜索价格:{{symbol}}{{price}} / 1K 次', { ? buildBillingPriceText('文件搜索价格:{{symbol}}{{price}} / 1K 次', {
symbol, symbol,
usdAmount: fileSearchPrice, usdAmount: fileSearchPrice,
rate, rate,
}) })
: null, : null,
imageGenerationCall && imageGenerationCallPrice > 0 imageGenerationCall && imageGenerationCallPrice > 0
? buildBillingPriceText('图片生成调用:{{symbol}}{{price}} / 1次', { ? buildBillingPriceText('图片生成调用:{{symbol}}{{price}} / 1次', {
symbol, symbol,
usdAmount: imageGenerationCallPrice, usdAmount: imageGenerationCallPrice,
rate, rate,
}) })
: null, : null,
buildBillingText( buildBillingText(
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = {{symbol}}{{total}}', '{{inputDesc}} + {{outputDesc}}{{extraServices}} = {{symbol}}{{total}}',
{ {
inputDesc, inputDesc,
outputDesc, outputDesc,
extraServices, extraServices,
symbol, symbol,
total: formatBillingDisplayPrice(price, rate), total: formatBillingDisplayPrice(price, rate),
}, },
), ),
]; ];
...@@ -1895,14 +1895,14 @@ export function renderModelPrice(opts) { ...@@ -1895,14 +1895,14 @@ export function renderModelPrice(opts) {
const displayPrice = (modelPrice * rate).toFixed(6); const displayPrice = (modelPrice * rate).toFixed(6);
const displayTotal = (modelPrice * groupRatio * rate).toFixed(6); const displayTotal = (modelPrice * groupRatio * rate).toFixed(6);
return i18next.t( return i18next.t(
'按次:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}', '按次:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
{ {
symbol: symbol, symbol: symbol,
price: displayPrice, price: displayPrice,
ratio: groupRatio, ratio: groupRatio,
total: displayTotal, total: displayTotal,
ratioType: ratioLabel, ratioType: ratioLabel,
}, },
); );
} }
...@@ -1913,49 +1913,49 @@ export function renderModelPrice(opts) { ...@@ -1913,49 +1913,49 @@ export function renderModelPrice(opts) {
const inputRatioPrice = modelRatio * 2.0; const inputRatioPrice = modelRatio * 2.0;
const completionRatioPrice = modelRatio * 2.0 * completionRatioValue; const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
const audioRatioValue = const audioRatioValue =
audioInputSeperatePrice && audioInputPrice > 0 audioInputSeperatePrice && audioInputPrice > 0
? formatRatioValue(audioInputPrice / inputRatioPrice) ? formatRatioValue(audioInputPrice / inputRatioPrice)
: null; : null;
const textInputTokens = Math.max( const textInputTokens = Math.max(
inputTokens - cacheTokens - audioInputTokens, inputTokens - cacheTokens - audioInputTokens,
0, 0,
); );
const imageInputTokens = const imageInputTokens =
image && imageOutputTokens > 0 ? imageOutputTokens : 0; image && imageOutputTokens > 0 ? imageOutputTokens : 0;
const cacheInputTokens = cacheTokens; const cacheInputTokens = cacheTokens;
const textInputAmount = const textInputAmount =
(textInputTokens / 1000000) * inputRatioPrice * groupRatio; (textInputTokens / 1000000) * inputRatioPrice * groupRatio;
const cacheInputAmount = const cacheInputAmount =
(cacheInputTokens / 1000000) * (cacheInputTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
cacheRatioValue * cacheRatioValue *
groupRatio; groupRatio;
const imageInputAmount = const imageInputAmount =
(imageInputTokens / 1000000) * (imageInputTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
imageRatioValue * imageRatioValue *
groupRatio; groupRatio;
const audioInputAmount = const audioInputAmount =
(audioInputTokens / 1000000) * audioInputPrice * groupRatio; (audioInputTokens / 1000000) * audioInputPrice * groupRatio;
const completionAmount = const completionAmount =
(completionTokens / 1000000) * completionRatioPrice * groupRatio; (completionTokens / 1000000) * completionRatioPrice * groupRatio;
const webSearchAmount = const webSearchAmount =
(webSearchCallCount / 1000) * webSearchPrice * groupRatio; (webSearchCallCount / 1000) * webSearchPrice * groupRatio;
const fileSearchAmount = const fileSearchAmount =
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio; (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
const imageGenerationAmount = imageGenerationCallPrice * groupRatio; const imageGenerationAmount = imageGenerationCallPrice * groupRatio;
const totalAmount = const totalAmount =
textInputAmount + textInputAmount +
cacheInputAmount + cacheInputAmount +
imageInputAmount + imageInputAmount +
audioInputAmount + audioInputAmount +
completionAmount + completionAmount +
webSearchAmount + webSearchAmount +
fileSearchAmount + fileSearchAmount +
imageGenerationAmount; imageGenerationAmount;
return renderBillingArticle([ return renderBillingArticle([
[ [
...@@ -1966,126 +1966,126 @@ export function renderModelPrice(opts) { ...@@ -1966,126 +1966,126 @@ export function renderModelPrice(opts) {
completionRatio: completionRatioValue, completionRatio: completionRatioValue,
}), }),
cacheInputTokens > 0 cacheInputTokens > 0
? buildBillingText('缓存倍率 {{cacheRatio}}', { ? buildBillingText('缓存倍率 {{cacheRatio}}', {
cacheRatio: cacheRatioValue, cacheRatio: cacheRatioValue,
}) })
: null, : null,
imageInputTokens > 0 imageInputTokens > 0
? buildBillingText('图片倍率 {{imageRatio}}', { ? buildBillingText('图片倍率 {{imageRatio}}', {
imageRatio: imageRatioValue, imageRatio: imageRatioValue,
}) })
: null, : null,
audioRatioValue !== null audioRatioValue !== null
? buildBillingText('音频倍率 {{audioRatio}}', { ? buildBillingText('音频倍率 {{audioRatio}}', {
audioRatio: audioRatioValue, audioRatio: audioRatioValue,
}) })
: null, : null,
buildBillingText('{{ratioType}} {{ratio}}', { buildBillingText('{{ratioType}} {{ratio}}', {
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
}), }),
] ]
.filter(Boolean) .filter(Boolean)
.join(','), .join(','),
textInputTokens > 0 textInputTokens > 0
? buildBillingText( ? buildBillingText(
'普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: textInputTokens, tokens: textInputTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd(textInputAmount), amount: renderDisplayAmountFromUsd(textInputAmount),
}, },
) )
: null, : null,
cacheInputTokens > 0 cacheInputTokens > 0
? buildBillingText( ? buildBillingText(
'缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: cacheInputTokens, tokens: cacheInputTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
cacheRatio: cacheRatioValue, cacheRatio: cacheRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd(cacheInputAmount), amount: renderDisplayAmountFromUsd(cacheInputAmount),
}, },
) )
: null, : null,
imageInputTokens > 0 imageInputTokens > 0
? buildBillingText( ? buildBillingText(
'图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: imageInputTokens, tokens: imageInputTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
imageRatio: imageRatioValue, imageRatio: imageRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd(imageInputAmount), amount: renderDisplayAmountFromUsd(imageInputAmount),
}, },
) )
: null, : null,
audioInputTokens > 0 && audioRatioValue !== null audioInputTokens > 0 && audioRatioValue !== null
? buildBillingText( ? buildBillingText(
'音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: audioInputTokens, tokens: audioInputTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
audioRatio: audioRatioValue, audioRatio: audioRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd(audioInputAmount), amount: renderDisplayAmountFromUsd(audioInputAmount),
}, },
) )
: null, : null,
buildBillingText( buildBillingText(
'输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: completionTokens, tokens: completionTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
completionRatio: completionRatioValue, completionRatio: completionRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd(completionAmount), amount: renderDisplayAmountFromUsd(completionAmount),
}, },
), ),
webSearch && webSearchCallCount > 0 webSearch && webSearchCallCount > 0
? buildBillingText( ? buildBillingText(
'Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}', 'Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
count: webSearchCallCount, count: webSearchCallCount,
price: renderDisplayAmountFromUsd(webSearchPrice), price: renderDisplayAmountFromUsd(webSearchPrice),
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd(webSearchAmount), amount: renderDisplayAmountFromUsd(webSearchAmount),
}, },
) )
: null, : null,
fileSearch && fileSearchCallCount > 0 fileSearch && fileSearchCallCount > 0
? buildBillingText( ? buildBillingText(
'文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}', '文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
count: fileSearchCallCount, count: fileSearchCallCount,
price: renderDisplayAmountFromUsd(fileSearchPrice), price: renderDisplayAmountFromUsd(fileSearchPrice),
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd(fileSearchAmount), amount: renderDisplayAmountFromUsd(fileSearchAmount),
}, },
) )
: null, : null,
imageGenerationCall && imageGenerationCallPrice > 0 imageGenerationCall && imageGenerationCallPrice > 0
? buildBillingText( ? buildBillingText(
'图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}', '图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
price: renderDisplayAmountFromUsd(imageGenerationCallPrice), price: renderDisplayAmountFromUsd(imageGenerationCallPrice),
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd(imageGenerationAmount), amount: renderDisplayAmountFromUsd(imageGenerationAmount),
}, },
) )
: null, : null,
buildBillingText('合计:{{total}}', { buildBillingText('合计:{{total}}', {
total: renderDisplayAmountFromUsd(totalAmount), total: renderDisplayAmountFromUsd(totalAmount),
}), }),
]); ]);
} }
...@@ -2137,38 +2137,38 @@ export function renderLogContent(opts) { ...@@ -2137,38 +2137,38 @@ export function renderLogContent(opts) {
}), }),
]; ];
appendPricePart( appendPricePart(
parts, parts,
cacheRatio !== 1.0, cacheRatio !== 1.0,
'缓存读取价格 {{symbol}}{{price}} / 1M tokens', '缓存读取价格 {{symbol}}{{price}} / 1M tokens',
{ {
symbol, symbol,
price: (modelRatio * 2.0 * cacheRatio * rate).toFixed(6), price: (modelRatio * 2.0 * cacheRatio * rate).toFixed(6),
}, },
); );
appendPricePart( appendPricePart(
parts, parts,
image, image,
'图片输入价格 {{symbol}}{{price}} / 1M tokens', '图片输入价格 {{symbol}}{{price}} / 1M tokens',
{ {
symbol, symbol,
price: (modelRatio * 2.0 * imageRatio * rate).toFixed(6), price: (modelRatio * 2.0 * imageRatio * rate).toFixed(6),
}, },
); );
appendPricePart( appendPricePart(
parts, parts,
webSearch, webSearch,
'Web 搜索调用 {{webSearchCallCount}} 次', 'Web 搜索调用 {{webSearchCallCount}} 次',
{ {
webSearchCallCount, webSearchCallCount,
}, },
); );
appendPricePart( appendPricePart(
parts, parts,
fileSearch, fileSearch,
'文件搜索调用 {{fileSearchCallCount}} 次', '文件搜索调用 {{fileSearchCallCount}} 次',
{ {
fileSearchCallCount, fileSearchCallCount,
}, },
); );
parts.push(getGroupRatioText(groupRatio, user_group_ratio)); parts.push(getGroupRatioText(groupRatio, user_group_ratio));
return joinBillingSummary(parts); return joinBillingSummary(parts);
...@@ -2184,38 +2184,38 @@ export function renderLogContent(opts) { ...@@ -2184,38 +2184,38 @@ export function renderLogContent(opts) {
} else { } else {
if (image) { if (image) {
return i18next.t( return i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}', '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}',
{ {
modelRatio: modelRatio, modelRatio: modelRatio,
cacheRatio: cacheRatio, cacheRatio: cacheRatio,
completionRatio: completionRatio, completionRatio: completionRatio,
imageRatio: imageRatio, imageRatio: imageRatio,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio, ratio,
}, },
); );
} else if (webSearch) { } else if (webSearch) {
return i18next.t( return i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次', '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次',
{ {
modelRatio: modelRatio, modelRatio: modelRatio,
cacheRatio: cacheRatio, cacheRatio: cacheRatio,
completionRatio: completionRatio, completionRatio: completionRatio,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio, ratio,
webSearchCallCount, webSearchCallCount,
}, },
); );
} else { } else {
return i18next.t( return i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}', '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}',
{ {
modelRatio: modelRatio, modelRatio: modelRatio,
cacheRatio: cacheRatio, cacheRatio: cacheRatio,
completionRatio: completionRatio, completionRatio: completionRatio,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio, ratio,
}, },
); );
} }
} }
...@@ -2270,6 +2270,37 @@ export function parseTiersFromExpr(exprStr) { ...@@ -2270,6 +2270,37 @@ export function parseTiersFromExpr(exprStr) {
} }
} }
export const decodeFromBase64 = (base64) => {
if (!base64) return '';
const binaryString =
typeof window !== 'undefined' ? window.atob(base64) : Buffer.from(base64, 'base64').toString('binary');
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder().decode(bytes);
}
return decodeURIComponent(
Array.prototype.map
.call(bytes, (byte) => '%' + byte.toString(16).padStart(2, '0'))
.join(''),
);
};
export const normalizeLabel = (label) => {
if (!label) return '';
return label
.replace(/<[==]?|≤|<[==]?/g, '<')
.replace(/>[==]?|≥|>[==]?/g, '>')
.replace(/\s+/g, '')
.toLowerCase();
};
export function renderTieredModelPrice(opts) { export function renderTieredModelPrice(opts) {
const { const {
prompt_tokens: inputTokens = 0, prompt_tokens: inputTokens = 0,
...@@ -2283,30 +2314,39 @@ export function renderTieredModelPrice(opts) { ...@@ -2283,30 +2314,39 @@ export function renderTieredModelPrice(opts) {
cache_creation_tokens_1h: cacheCreationTokens1h = 0, cache_creation_tokens_1h: cacheCreationTokens1h = 0,
} = opts; } = opts;
let exprStr = ''; let exprStr = '';
try { exprStr = atob(exprB64); } catch { /* ignore */ } try { exprStr = decodeFromBase64(exprB64); } catch { /* ignore */ }
const tiers = parseTiersFromExpr(exprStr); const tiers = parseTiersFromExpr(exprStr);
if (tiers.length === 0) { if (tiers.length === 0) {
return i18next.t('阶梯计费(表达式解析失败)'); return i18next.t('阶梯计费(表达式解析失败)');
} }
const tier = tiers.find((t) => t.label === matchedTier) || tiers[0]; const tier =
tiers.find((t) => {
const l1 = normalizeLabel(t.label);
const l2 = normalizeLabel(matchedTier);
return l1 === l2 && l1 !== '';
});
if (!tier) {
return i18next.t('阶梯计费(未匹配到对应阶梯)');
}
const { symbol, rate } = getCurrencyConfig(); const { symbol, rate } = getCurrencyConfig();
const gr = groupRatio || 1; const gr = groupRatio || 1;
const hasAnyCacheTokens = cacheTokens > 0 || cacheCreationTokens > 0 const hasAnyCacheTokens = cacheTokens > 0 || cacheCreationTokens > 0
|| cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; || cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const priceLines = BILLING_PRICING_VARS const priceLines = BILLING_PRICING_VARS
.filter((v) => v.group !== 'cache' || hasAnyCacheTokens) .filter((v) => v.group !== 'cache' || hasAnyCacheTokens)
.map((v) => [v.field, v.label]); .map((v) => [v.field, v.label]);
const lines = [ const lines = [
buildBillingText('命中档位:{{tier}}', { tier: matchedTier || tier.label }), buildBillingText('命中档位:{{tier}}', { tier: matchedTier || tier.label }),
...priceLines ...priceLines
.filter(([field]) => tier[field] > 0) .filter(([field]) => tier[field] > 0)
.map(([field, label]) => .map(([field, label]) =>
buildBillingPriceText(`${label}:{{symbol}}{{price}} / 1M tokens`, { symbol, usdAmount: tier[field], rate }), buildBillingPriceText(`${label}:{{symbol}}{{price}} / 1M tokens`, { symbol, usdAmount: tier[field], rate }),
), ),
]; ];
return renderBillingArticle(lines); return renderBillingArticle(lines);
...@@ -2326,9 +2366,14 @@ export function renderTieredModelPriceSimple(opts) { ...@@ -2326,9 +2366,14 @@ export function renderTieredModelPriceSimple(opts) {
outputMode = 'segments', outputMode = 'segments',
} = opts; } = opts;
let exprStr = ''; let exprStr = '';
try { exprStr = atob(exprB64); } catch { /* ignore */ } try { exprStr = decodeFromBase64(exprB64); } catch { /* ignore */ }
const tiers = parseTiersFromExpr(exprStr); const tiers = parseTiersFromExpr(exprStr);
const tier = tiers.find((t) => t.label === matchedTier) || tiers[0]; const tier =
tiers.find((t) => {
const l1 = normalizeLabel(t.label);
const l2 = normalizeLabel(matchedTier);
return l1 === l2 && l1 !== '';
});
if (outputMode === 'segments') { if (outputMode === 'segments') {
const segments = [ const segments = [
...@@ -2338,12 +2383,19 @@ export function renderTieredModelPriceSimple(opts) { ...@@ -2338,12 +2383,19 @@ export function renderTieredModelPriceSimple(opts) {
}, },
]; ];
if (tier && isPriceDisplayMode(displayMode)) { if (!tier) {
segments.push({
tone: 'secondary',
text: tiers.length === 0
? i18next.t('阶梯计费(表达式解析失败)')
: i18next.t('阶梯计费(未匹配到对应阶梯)'),
});
} else if (isPriceDisplayMode(displayMode)) {
const hasAnyCacheTokens = cacheTokens > 0 || cacheCreationTokens > 0 const hasAnyCacheTokens = cacheTokens > 0 || cacheCreationTokens > 0
|| cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; || cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const priceSegments = BILLING_PRICING_VARS const priceSegments = BILLING_PRICING_VARS
.filter((v) => v.group !== 'cache' || hasAnyCacheTokens) .filter((v) => v.group !== 'cache' || hasAnyCacheTokens)
.map((v) => [v.field, v.shortLabel]); .map((v) => [v.field, v.shortLabel]);
for (const [field, label] of priceSegments) { for (const [field, label] of priceSegments) {
if (tier[field] > 0) { if (tier[field] > 0) {
segments.push({ segments.push({
...@@ -2423,8 +2475,8 @@ export function renderAudioModelPrice(opts) { ...@@ -2423,8 +2475,8 @@ export function renderAudioModelPrice(opts) {
displayMode = 'price', displayMode = 'price',
} = opts; } = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
_groupRatio, _groupRatio,
user_group_ratio, user_group_ratio,
); );
let groupRatio = effectiveGroupRatio; let groupRatio = effectiveGroupRatio;
const completionRatio = _completionRatio ?? 0; const completionRatio = _completionRatio ?? 0;
...@@ -2443,15 +2495,15 @@ export function renderAudioModelPrice(opts) { ...@@ -2443,15 +2495,15 @@ export function renderAudioModelPrice(opts) {
rate, rate,
}), }),
buildBillingPriceText( buildBillingPriceText(
'模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', '模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{ {
symbol, symbol,
usdAmount: modelPrice, usdAmount: modelPrice,
rate, rate,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
total: formatBillingDisplayPrice(modelPrice * groupRatio, rate), total: formatBillingDisplayPrice(modelPrice * groupRatio, rate),
}, },
), ),
]); ]);
} }
...@@ -2459,13 +2511,13 @@ export function renderAudioModelPrice(opts) { ...@@ -2459,13 +2511,13 @@ export function renderAudioModelPrice(opts) {
const inputRatioPrice = modelRatio * 2.0; const inputRatioPrice = modelRatio * 2.0;
const completionRatioPrice = modelRatio * 2.0 * completionRatio; const completionRatioPrice = modelRatio * 2.0 * completionRatio;
const textPrice = const textPrice =
((inputTokens - cacheTokens + cacheTokens * cacheRatio) / 1000000) * ((inputTokens - cacheTokens + cacheTokens * cacheRatio) / 1000000) *
inputRatioPrice * inputRatioPrice *
groupRatio + groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio; (completionTokens / 1000000) * completionRatioPrice * groupRatio;
const audioPrice = const audioPrice =
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio + (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
(audioCompletionTokens / 1000000) * (audioCompletionTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
audioRatio * audioRatio *
audioCompletionRatio * audioCompletionRatio *
...@@ -2484,15 +2536,15 @@ export function renderAudioModelPrice(opts) { ...@@ -2484,15 +2536,15 @@ export function renderAudioModelPrice(opts) {
rate, rate,
}), }),
cacheTokens > 0 cacheTokens > 0
? buildBillingPriceText( ? buildBillingPriceText(
'缓存读取价格:{{symbol}}{{price}} / 1M tokens', '缓存读取价格:{{symbol}}{{price}} / 1M tokens',
{ {
symbol, symbol,
usdAmount: inputRatioPrice * cacheRatio, usdAmount: inputRatioPrice * cacheRatio,
rate, rate,
}, },
) )
: null, : null,
buildBillingPriceText('音频输入价格:{{symbol}}{{price}} / 1M tokens', { buildBillingPriceText('音频输入价格:{{symbol}}{{price}} / 1M tokens', {
symbol, symbol,
usdAmount: inputRatioPrice * audioRatio, usdAmount: inputRatioPrice * audioRatio,
...@@ -2504,27 +2556,27 @@ export function renderAudioModelPrice(opts) { ...@@ -2504,27 +2556,27 @@ export function renderAudioModelPrice(opts) {
rate, rate,
}), }),
buildBillingText( buildBillingText(
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{textInputPrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 音频提示 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{audioCompletion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', '文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{textInputPrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 音频提示 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{audioCompletion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{ {
input: inputTokens, input: inputTokens,
completion: completionTokens, completion: completionTokens,
audioInput: audioInputTokens, audioInput: audioInputTokens,
audioCompletion: audioCompletionTokens, audioCompletion: audioCompletionTokens,
textInputPrice: formatBillingDisplayPrice(inputRatioPrice, rate), textInputPrice: formatBillingDisplayPrice(inputRatioPrice, rate),
textCompPrice: formatBillingDisplayPrice(completionRatioPrice, rate), textCompPrice: formatBillingDisplayPrice(completionRatioPrice, rate),
audioInputPrice: formatBillingDisplayPrice( audioInputPrice: formatBillingDisplayPrice(
audioRatio * inputRatioPrice, audioRatio * inputRatioPrice,
rate, rate,
), ),
audioCompPrice: formatBillingDisplayPrice( audioCompPrice: formatBillingDisplayPrice(
audioRatio * audioCompletionRatio * inputRatioPrice, audioRatio * audioCompletionRatio * inputRatioPrice,
rate, rate,
), ),
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
symbol, symbol,
total: formatBillingDisplayPrice(totalPrice, rate), total: formatBillingDisplayPrice(totalPrice, rate),
}, },
), ),
]); ]);
} }
...@@ -2532,14 +2584,14 @@ export function renderAudioModelPrice(opts) { ...@@ -2532,14 +2584,14 @@ export function renderAudioModelPrice(opts) {
// 1 ratio = $0.002 / 1K tokens // 1 ratio = $0.002 / 1K tokens
if (modelPrice !== -1) { if (modelPrice !== -1) {
return i18next.t( return i18next.t(
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}', '模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
{ {
symbol: symbol, symbol: symbol,
price: (modelPrice * rate).toFixed(6), price: (modelPrice * rate).toFixed(6),
ratio: groupRatio, ratio: groupRatio,
total: (modelPrice * groupRatio * rate).toFixed(6), total: (modelPrice * groupRatio * rate).toFixed(6),
ratioType: ratioLabel, ratioType: ratioLabel,
}, },
); );
} }
...@@ -2553,17 +2605,17 @@ export function renderAudioModelPrice(opts) { ...@@ -2553,17 +2605,17 @@ export function renderAudioModelPrice(opts) {
const completionRatioPrice = modelRatio * 2.0 * completionRatioValue; const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
const effectiveInputTokens = const effectiveInputTokens =
inputTokens - cacheTokens + cacheTokens * cacheRatioValue; inputTokens - cacheTokens + cacheTokens * cacheRatioValue;
const textPrice = const textPrice =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio + (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio; (completionTokens / 1000000) * completionRatioPrice * groupRatio;
const audioPrice = const audioPrice =
(audioInputTokens / 1000000) * (audioInputTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
audioRatioValue * audioRatioValue *
groupRatio + groupRatio +
(audioCompletionTokens / 1000000) * (audioCompletionTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
audioRatioValue * audioRatioValue *
audioCompletionRatioValue * audioCompletionRatioValue *
...@@ -2572,109 +2624,109 @@ export function renderAudioModelPrice(opts) { ...@@ -2572,109 +2624,109 @@ export function renderAudioModelPrice(opts) {
return renderBillingArticle([ return renderBillingArticle([
buildBillingText( buildBillingText(
'模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}', '模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}',
{ {
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
completionRatio: completionRatioValue, completionRatio: completionRatioValue,
audioRatio: audioRatioValue, audioRatio: audioRatioValue,
audioCompletionRatio: audioCompletionRatioValue, audioCompletionRatio: audioCompletionRatioValue,
cachePart: cachePart:
cacheTokens > 0 cacheTokens > 0
? `${i18next.t('缓存倍率')} ${cacheRatioValue},` ? `${i18next.t('缓存倍率')} ${cacheRatioValue},`
: '', : '',
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
}, },
), ),
buildBillingText( buildBillingText(
'普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: Math.max(inputTokens - cacheTokens, 0), tokens: Math.max(inputTokens - cacheTokens, 0),
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(Math.max(inputTokens - cacheTokens, 0) / 1000000) * (Math.max(inputTokens - cacheTokens, 0) / 1000000) *
inputRatioPrice * inputRatioPrice *
groupRatio, groupRatio,
), ),
}, },
), ),
cacheTokens > 0 cacheTokens > 0
? buildBillingText( ? buildBillingText(
'缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: cacheTokens, tokens: cacheTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
cacheRatio: cacheRatioValue, cacheRatio: cacheRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(cacheTokens / 1000000) * (cacheTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
cacheRatioValue * cacheRatioValue *
groupRatio, groupRatio,
), ),
}, },
) )
: null, : null,
buildBillingText( buildBillingText(
'文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: completionTokens, tokens: completionTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
completionRatio: completionRatioValue, completionRatio: completionRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(completionTokens / 1000000) * (completionTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
completionRatioValue * completionRatioValue *
groupRatio, groupRatio,
), ),
}, },
), ),
buildBillingText( buildBillingText(
'音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: audioInputTokens, tokens: audioInputTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
audioRatio: audioRatioValue, audioRatio: audioRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(audioInputTokens / 1000000) * (audioInputTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
audioRatioValue * audioRatioValue *
groupRatio, groupRatio,
), ),
}, },
), ),
buildBillingText( buildBillingText(
'音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: audioCompletionTokens, tokens: audioCompletionTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
audioRatio: audioRatioValue, audioRatio: audioRatioValue,
audioCompletionRatio: audioCompletionRatioValue, audioCompletionRatio: audioCompletionRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(audioCompletionTokens / 1000000) * (audioCompletionTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
audioRatioValue * audioRatioValue *
audioCompletionRatioValue * audioCompletionRatioValue *
groupRatio, groupRatio,
), ),
}, },
), ),
buildBillingText( buildBillingText(
'合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}', '合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}',
{ {
textTotal: renderDisplayAmountFromUsd(textPrice), textTotal: renderDisplayAmountFromUsd(textPrice),
audioTotal: renderDisplayAmountFromUsd(audioPrice), audioTotal: renderDisplayAmountFromUsd(audioPrice),
total: renderDisplayAmountFromUsd(totalPrice), total: renderDisplayAmountFromUsd(totalPrice),
}, },
), ),
]); ]);
} }
...@@ -2707,8 +2759,8 @@ export function renderClaudeModelPrice(opts) { ...@@ -2707,8 +2759,8 @@ export function renderClaudeModelPrice(opts) {
displayMode = 'price', displayMode = 'price',
} = opts; } = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
_groupRatio, _groupRatio,
user_group_ratio, user_group_ratio,
); );
let groupRatio = effectiveGroupRatio; let groupRatio = effectiveGroupRatio;
const completionRatio = _completionRatio ?? 0; const completionRatio = _completionRatio ?? 0;
...@@ -2725,15 +2777,15 @@ export function renderClaudeModelPrice(opts) { ...@@ -2725,15 +2777,15 @@ export function renderClaudeModelPrice(opts) {
rate, rate,
}), }),
buildBillingPriceText( buildBillingPriceText(
'模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', '模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{ {
symbol, symbol,
usdAmount: modelPrice, usdAmount: modelPrice,
rate, rate,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
total: formatBillingDisplayPrice(modelPrice * groupRatio, rate), total: formatBillingDisplayPrice(modelPrice * groupRatio, rate),
}, },
), ),
]); ]);
} }
...@@ -2745,19 +2797,19 @@ export function renderClaudeModelPrice(opts) { ...@@ -2745,19 +2797,19 @@ export function renderClaudeModelPrice(opts) {
const cacheCreationRatioPrice5m = modelRatio * 2.0 * cacheCreationRatio5m; const cacheCreationRatioPrice5m = modelRatio * 2.0 * cacheCreationRatio5m;
const cacheCreationRatioPrice1h = modelRatio * 2.0 * cacheCreationRatio1h; const cacheCreationRatioPrice1h = modelRatio * 2.0 * cacheCreationRatio1h;
const hasSplitCacheCreation = const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const legacyCacheCreationTokens = hasSplitCacheCreation const legacyCacheCreationTokens = hasSplitCacheCreation
? 0 ? 0
: cacheCreationTokens; : cacheCreationTokens;
const effectiveInputTokens = const effectiveInputTokens =
inputTokens + inputTokens +
cacheTokens * cacheRatio + cacheTokens * cacheRatio +
legacyCacheCreationTokens * cacheCreationRatio + legacyCacheCreationTokens * cacheCreationRatio +
cacheCreationTokens5m * cacheCreationRatio5m + cacheCreationTokens5m * cacheCreationRatio5m +
cacheCreationTokens1h * cacheCreationRatio1h; cacheCreationTokens1h * cacheCreationRatio1h;
const price = const price =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio + (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio; (completionTokens / 1000000) * completionRatioPrice * groupRatio;
const inputUnitPrice = inputRatioPrice * rate; const inputUnitPrice = inputRatioPrice * rate;
const completionUnitPrice = completionRatioPrice * rate; const completionUnitPrice = completionRatioPrice * rate;
const cacheUnitPrice = cacheRatioPrice * rate; const cacheUnitPrice = cacheRatioPrice * rate;
...@@ -2765,14 +2817,14 @@ export function renderClaudeModelPrice(opts) { ...@@ -2765,14 +2817,14 @@ export function renderClaudeModelPrice(opts) {
const cacheCreationUnitPrice5m = cacheCreationRatioPrice5m * rate; const cacheCreationUnitPrice5m = cacheCreationRatioPrice5m * rate;
const cacheCreationUnitPrice1h = cacheCreationRatioPrice1h * rate; const cacheCreationUnitPrice1h = cacheCreationRatioPrice1h * rate;
const cacheCreationUnitPriceTotal = const cacheCreationUnitPriceTotal =
cacheCreationUnitPrice5m + cacheCreationUnitPrice1h; cacheCreationUnitPrice5m + cacheCreationUnitPrice1h;
const shouldShowCache = cacheTokens > 0; const shouldShowCache = cacheTokens > 0;
const shouldShowLegacyCacheCreation = const shouldShowLegacyCacheCreation =
!hasSplitCacheCreation && cacheCreationTokens > 0; !hasSplitCacheCreation && cacheCreationTokens > 0;
const shouldShowCacheCreation5m = const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0; hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h = const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0; hasSplitCacheCreation && cacheCreationTokens1h > 0;
const breakdownSegments = [ const breakdownSegments = [
i18next.t('提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}}', { i18next.t('提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}}', {
...@@ -2784,62 +2836,62 @@ export function renderClaudeModelPrice(opts) { ...@@ -2784,62 +2836,62 @@ export function renderClaudeModelPrice(opts) {
if (shouldShowCache) { if (shouldShowCache) {
breakdownSegments.push( breakdownSegments.push(
i18next.t('缓存 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}', { i18next.t('缓存 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}', {
tokens: cacheTokens, tokens: cacheTokens,
symbol, symbol,
price: cacheUnitPrice.toFixed(6), price: cacheUnitPrice.toFixed(6),
}), }),
); );
} }
if (shouldShowLegacyCacheCreation) { if (shouldShowLegacyCacheCreation) {
breakdownSegments.push( breakdownSegments.push(
i18next.t( i18next.t(
'缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}', '缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}',
{ {
tokens: cacheCreationTokens, tokens: cacheCreationTokens,
symbol, symbol,
price: cacheCreationUnitPrice.toFixed(6), price: cacheCreationUnitPrice.toFixed(6),
}, },
), ),
); );
} }
if (shouldShowCacheCreation5m) { if (shouldShowCacheCreation5m) {
breakdownSegments.push( breakdownSegments.push(
i18next.t( i18next.t(
'5m缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}', '5m缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}',
{ {
tokens: cacheCreationTokens5m, tokens: cacheCreationTokens5m,
symbol, symbol,
price: cacheCreationUnitPrice5m.toFixed(6), price: cacheCreationUnitPrice5m.toFixed(6),
}, },
), ),
); );
} }
if (shouldShowCacheCreation1h) { if (shouldShowCacheCreation1h) {
breakdownSegments.push( breakdownSegments.push(
i18next.t( i18next.t(
'1h缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}', '1h缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}',
{ {
tokens: cacheCreationTokens1h, tokens: cacheCreationTokens1h,
symbol, symbol,
price: cacheCreationUnitPrice1h.toFixed(6), price: cacheCreationUnitPrice1h.toFixed(6),
}, },
), ),
); );
} }
breakdownSegments.push( breakdownSegments.push(
i18next.t( i18next.t(
'补全 {{completion}} tokens / 1M tokens * {{symbol}}{{price}}', '补全 {{completion}} tokens / 1M tokens * {{symbol}}{{price}}',
{ {
completion: completionTokens, completion: completionTokens,
symbol, symbol,
price: completionUnitPrice.toFixed(6), price: completionUnitPrice.toFixed(6),
}, },
), ),
); );
const breakdownText = breakdownSegments.join(' + '); const breakdownText = breakdownSegments.join(' + ');
...@@ -2856,68 +2908,68 @@ export function renderClaudeModelPrice(opts) { ...@@ -2856,68 +2908,68 @@ export function renderClaudeModelPrice(opts) {
rate, rate,
}), }),
cacheTokens > 0 cacheTokens > 0
? buildBillingPriceText( ? buildBillingPriceText(
'缓存读取价格:{{symbol}}{{price}} / 1M tokens', '缓存读取价格:{{symbol}}{{price}} / 1M tokens',
{ {
symbol, symbol,
usdAmount: cacheRatioPrice, usdAmount: cacheRatioPrice,
rate, rate,
}, },
) )
: null, : null,
!hasSplitCacheCreation && cacheCreationTokens > 0 !hasSplitCacheCreation && cacheCreationTokens > 0
? buildBillingPriceText( ? buildBillingPriceText(
'缓存创建价格:{{symbol}}{{price}} / 1M tokens', '缓存创建价格:{{symbol}}{{price}} / 1M tokens',
{ {
symbol, symbol,
usdAmount: cacheCreationRatioPrice, usdAmount: cacheCreationRatioPrice,
rate, rate,
}, },
) )
: null, : null,
hasSplitCacheCreation && cacheCreationTokens5m > 0 hasSplitCacheCreation && cacheCreationTokens5m > 0
? buildBillingPriceText( ? buildBillingPriceText(
'5m缓存创建价格:{{symbol}}{{price}} / 1M tokens', '5m缓存创建价格:{{symbol}}{{price}} / 1M tokens',
{ {
symbol, symbol,
usdAmount: cacheCreationRatioPrice5m, usdAmount: cacheCreationRatioPrice5m,
rate, rate,
}, },
) )
: null, : null,
hasSplitCacheCreation && cacheCreationTokens1h > 0 hasSplitCacheCreation && cacheCreationTokens1h > 0
? buildBillingPriceText( ? buildBillingPriceText(
'1h缓存创建价格:{{symbol}}{{price}} / 1M tokens', '1h缓存创建价格:{{symbol}}{{price}} / 1M tokens',
{ {
symbol, symbol,
usdAmount: cacheCreationRatioPrice1h, usdAmount: cacheCreationRatioPrice1h,
rate, rate,
}, },
) )
: null, : null,
buildBillingText( buildBillingText(
'{{breakdown}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', '{{breakdown}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{ {
breakdown: breakdownText, breakdown: breakdownText,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
symbol, symbol,
total: formatBillingDisplayPrice(price, rate), total: formatBillingDisplayPrice(price, rate),
}, },
), ),
]); ]);
} }
if (modelPrice !== -1) { if (modelPrice !== -1) {
return i18next.t( return i18next.t(
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}', '模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
{ {
symbol: symbol, symbol: symbol,
price: (modelPrice * rate).toFixed(6), price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
total: (modelPrice * groupRatio * rate).toFixed(6), total: (modelPrice * groupRatio * rate).toFixed(6),
}, },
); );
} }
...@@ -2932,157 +2984,157 @@ export function renderClaudeModelPrice(opts) { ...@@ -2932,157 +2984,157 @@ export function renderClaudeModelPrice(opts) {
const completionRatioPrice = modelRatio * 2.0 * completionRatioValue; const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
const hasSplitCacheCreation = const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const shouldShowCache = cacheTokens > 0; const shouldShowCache = cacheTokens > 0;
const shouldShowLegacyCacheCreation = const shouldShowLegacyCacheCreation =
!hasSplitCacheCreation && cacheCreationTokens > 0; !hasSplitCacheCreation && cacheCreationTokens > 0;
const shouldShowCacheCreation5m = const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0; hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h = const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0; hasSplitCacheCreation && cacheCreationTokens1h > 0;
const legacyCacheCreationTokens = hasSplitCacheCreation const legacyCacheCreationTokens = hasSplitCacheCreation
? 0 ? 0
: cacheCreationTokens; : cacheCreationTokens;
const effectiveInputTokens = const effectiveInputTokens =
inputTokens + inputTokens +
cacheTokens * cacheRatioValue + cacheTokens * cacheRatioValue +
legacyCacheCreationTokens * cacheCreationRatioValue + legacyCacheCreationTokens * cacheCreationRatioValue +
cacheCreationTokens5m * cacheCreationRatio5mValue + cacheCreationTokens5m * cacheCreationRatio5mValue +
cacheCreationTokens1h * cacheCreationRatio1hValue; cacheCreationTokens1h * cacheCreationRatio1hValue;
const totalAmount = const totalAmount =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio + (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio; (completionTokens / 1000000) * completionRatioPrice * groupRatio;
return renderBillingArticle([ return renderBillingArticle([
buildBillingText( buildBillingText(
'模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}', '模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}',
{ {
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
completionRatio: completionRatioValue, completionRatio: completionRatioValue,
cacheRatio: cacheRatioValue, cacheRatio: cacheRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
}, },
), ),
hasSplitCacheCreation hasSplitCacheCreation
? buildBillingText( ? buildBillingText(
'缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}', '缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
{ {
cacheCreationRatio5m: cacheCreationRatio5mValue, cacheCreationRatio5m: cacheCreationRatio5mValue,
cacheCreationRatio1h: cacheCreationRatio1hValue, cacheCreationRatio1h: cacheCreationRatio1hValue,
}, },
) )
: buildBillingText('缓存创建倍率 {{cacheCreationRatio}}', { : buildBillingText('缓存创建倍率 {{cacheCreationRatio}}', {
cacheCreationRatio: cacheCreationRatioValue, cacheCreationRatio: cacheCreationRatioValue,
}), }),
buildBillingText( buildBillingText(
'普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: inputTokens, tokens: inputTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(inputTokens / 1000000) * inputRatioPrice * groupRatio, (inputTokens / 1000000) * inputRatioPrice * groupRatio,
), ),
}, },
), ),
shouldShowCache shouldShowCache
? buildBillingText( ? buildBillingText(
'缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: cacheTokens, tokens: cacheTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
cacheRatio: cacheRatioValue, cacheRatio: cacheRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(cacheTokens / 1000000) * (cacheTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
cacheRatioValue * cacheRatioValue *
groupRatio, groupRatio,
), ),
}, },
) )
: null, : null,
shouldShowLegacyCacheCreation shouldShowLegacyCacheCreation
? buildBillingText( ? buildBillingText(
'缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: cacheCreationTokens, tokens: cacheCreationTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
cacheCreationRatio: cacheCreationRatioValue, cacheCreationRatio: cacheCreationRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(cacheCreationTokens / 1000000) * (cacheCreationTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
cacheCreationRatioValue * cacheCreationRatioValue *
groupRatio, groupRatio,
), ),
}, },
) )
: null, : null,
shouldShowCacheCreation5m shouldShowCacheCreation5m
? buildBillingText( ? buildBillingText(
'5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}', '5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: cacheCreationTokens5m, tokens: cacheCreationTokens5m,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
cacheCreationRatio5m: cacheCreationRatio5mValue, cacheCreationRatio5m: cacheCreationRatio5mValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(cacheCreationTokens5m / 1000000) * (cacheCreationTokens5m / 1000000) *
inputRatioPrice * inputRatioPrice *
cacheCreationRatio5mValue * cacheCreationRatio5mValue *
groupRatio, groupRatio,
), ),
}, },
) )
: null, : null,
shouldShowCacheCreation1h shouldShowCacheCreation1h
? buildBillingText( ? buildBillingText(
'1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}', '1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: cacheCreationTokens1h, tokens: cacheCreationTokens1h,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
cacheCreationRatio1h: cacheCreationRatio1hValue, cacheCreationRatio1h: cacheCreationRatio1hValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(cacheCreationTokens1h / 1000000) * (cacheCreationTokens1h / 1000000) *
inputRatioPrice * inputRatioPrice *
cacheCreationRatio1hValue * cacheCreationRatio1hValue *
groupRatio, groupRatio,
), ),
}, },
) )
: null, : null,
buildBillingText( buildBillingText(
'补全 {{completion}} tokens * 输出倍率 {{completionRatio}}', '补全 {{completion}} tokens * 输出倍率 {{completionRatio}}',
{ {
completion: completionTokens, completion: completionTokens,
completionRatio: completionRatioValue, completionRatio: completionRatioValue,
}, },
), ),
buildBillingText( buildBillingText(
'输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}', '输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{ {
tokens: completionTokens, tokens: completionTokens,
modelRatio: modelRatioValue, modelRatio: modelRatioValue,
completionRatio: completionRatioValue, completionRatio: completionRatioValue,
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
amount: renderDisplayAmountFromUsd( amount: renderDisplayAmountFromUsd(
(completionTokens / 1000000) * (completionTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
completionRatioValue * completionRatioValue *
groupRatio, groupRatio,
), ),
}, },
), ),
buildBillingText('合计:{{total}}', { buildBillingText('合计:{{total}}', {
total: renderDisplayAmountFromUsd(totalAmount), total: renderDisplayAmountFromUsd(totalAmount),
...@@ -3106,8 +3158,8 @@ export function renderClaudeLogContent(opts) { ...@@ -3106,8 +3158,8 @@ export function renderClaudeLogContent(opts) {
displayMode = 'price', displayMode = 'price',
} = opts; } = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
_groupRatio, _groupRatio,
user_group_ratio, user_group_ratio,
); );
let groupRatio = effectiveGroupRatio; let groupRatio = effectiveGroupRatio;
...@@ -3140,33 +3192,33 @@ export function renderClaudeLogContent(opts) { ...@@ -3140,33 +3192,33 @@ export function renderClaudeLogContent(opts) {
}), }),
]; ];
const hasSplitCacheCreation = const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
appendPricePart( appendPricePart(
parts, parts,
hasSplitCacheCreation && cacheCreationTokens5m > 0, hasSplitCacheCreation && cacheCreationTokens5m > 0,
'5m缓存创建价格 {{symbol}}{{price}} / 1M tokens', '5m缓存创建价格 {{symbol}}{{price}} / 1M tokens',
{ {
symbol, symbol,
price: (modelRatio * 2.0 * cacheCreationRatio5m * rate).toFixed(6), price: (modelRatio * 2.0 * cacheCreationRatio5m * rate).toFixed(6),
}, },
); );
appendPricePart( appendPricePart(
parts, parts,
hasSplitCacheCreation && cacheCreationTokens1h > 0, hasSplitCacheCreation && cacheCreationTokens1h > 0,
'1h缓存创建价格 {{symbol}}{{price}} / 1M tokens', '1h缓存创建价格 {{symbol}}{{price}} / 1M tokens',
{ {
symbol, symbol,
price: (modelRatio * 2.0 * cacheCreationRatio1h * rate).toFixed(6), price: (modelRatio * 2.0 * cacheCreationRatio1h * rate).toFixed(6),
}, },
); );
appendPricePart( appendPricePart(
parts, parts,
!hasSplitCacheCreation, !hasSplitCacheCreation,
'缓存创建价格 {{symbol}}{{price}} / 1M tokens', '缓存创建价格 {{symbol}}{{price}} / 1M tokens',
{ {
symbol, symbol,
price: (modelRatio * 2.0 * cacheCreationRatio * rate).toFixed(6), price: (modelRatio * 2.0 * cacheCreationRatio * rate).toFixed(6),
}, },
); );
parts.push(getGroupRatioText(groupRatio, user_group_ratio)); parts.push(getGroupRatioText(groupRatio, user_group_ratio));
return joinBillingSummary(parts); return joinBillingSummary(parts);
...@@ -3181,35 +3233,35 @@ export function renderClaudeLogContent(opts) { ...@@ -3181,35 +3233,35 @@ export function renderClaudeLogContent(opts) {
}); });
} else { } else {
const hasSplitCacheCreation = const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const shouldShowCacheCreation5m = const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0; hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h = const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0; hasSplitCacheCreation && cacheCreationTokens1h > 0;
let cacheCreationPart = null; let cacheCreationPart = null;
if (hasSplitCacheCreation) { if (hasSplitCacheCreation) {
if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) { if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) {
cacheCreationPart = i18next.t( cacheCreationPart = i18next.t(
'缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}', '缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
{ {
cacheCreationRatio5m, cacheCreationRatio5m,
cacheCreationRatio1h, cacheCreationRatio1h,
}, },
); );
} else if (shouldShowCacheCreation5m) { } else if (shouldShowCacheCreation5m) {
cacheCreationPart = i18next.t( cacheCreationPart = i18next.t(
'缓存创建倍率 5m {{cacheCreationRatio5m}}', '缓存创建倍率 5m {{cacheCreationRatio5m}}',
{ {
cacheCreationRatio5m, cacheCreationRatio5m,
}, },
); );
} else if (shouldShowCacheCreation1h) { } else if (shouldShowCacheCreation1h) {
cacheCreationPart = i18next.t( cacheCreationPart = i18next.t(
'缓存创建倍率 1h {{cacheCreationRatio1h}}', '缓存创建倍率 1h {{cacheCreationRatio1h}}',
{ {
cacheCreationRatio1h, cacheCreationRatio1h,
}, },
); );
} }
} }
...@@ -3249,10 +3301,10 @@ export function rehypeSplitWordsIntoSpans(options = {}) { ...@@ -3249,10 +3301,10 @@ export function rehypeSplitWordsIntoSpans(options = {}) {
visit(tree, 'element', (node) => { visit(tree, 'element', (node) => {
if ( if (
['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes( ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(
node.tagName, node.tagName,
) && ) &&
node.children node.children
) { ) {
const newChildren = []; const newChildren = [];
node.children.forEach((child) => { node.children.forEach((child) => {
...@@ -3265,26 +3317,26 @@ export function rehypeSplitWordsIntoSpans(options = {}) { ...@@ -3265,26 +3317,26 @@ export function rehypeSplitWordsIntoSpans(options = {}) {
const segments = segmenter.segment(child.value); const segments = segmenter.segment(child.value);
Array.from(segments) Array.from(segments)
.map((seg) => seg.segment) .map((seg) => seg.segment)
.filter(Boolean) .filter(Boolean)
.forEach((word) => { .forEach((word) => {
const wordStartPos = currentCharCount; const wordStartPos = currentCharCount;
const wordEndPos = currentCharCount + word.length; const wordEndPos = currentCharCount + word.length;
// 判断这个词是否是新增的(在 previousContentLength 之后) // 判断这个词是否是新增的(在 previousContentLength 之后)
const isNewContent = wordStartPos >= previousContentLength; const isNewContent = wordStartPos >= previousContentLength;
newChildren.push({ newChildren.push({
type: 'element', type: 'element',
tagName: 'span', tagName: 'span',
properties: { properties: {
className: isNewContent ? ['animate-fade-in'] : [], className: isNewContent ? ['animate-fade-in'] : [],
}, },
children: [{ type: 'text', value: word }], children: [{ type: 'text', value: word }],
});
currentCharCount = wordEndPos;
}); });
currentCharCount = wordEndPos;
});
} catch (_) { } catch (_) {
// Fallback:如果浏览器不支持 Segmenter // Fallback:如果浏览器不支持 Segmenter
const textStartPos = currentCharCount; const textStartPos = currentCharCount;
......
...@@ -3689,6 +3689,8 @@ ...@@ -3689,6 +3689,8 @@
"缓存创建-5分钟 (cc5)": "Cache Creation-5min (cc5)", "缓存创建-5分钟 (cc5)": "Cache Creation-5min (cc5)",
"缓存创建-1小时 (cc1h)": "Cache Creation-1hour (cc1h)", "缓存创建-1小时 (cc1h)": "Cache Creation-1hour (cc1h)",
"阶梯计费": "Tiered Billing", "阶梯计费": "Tiered Billing",
"阶梯计费(表达式解析失败)": "Tiered Billing (expression parse failed)",
"阶梯计费(未匹配到对应阶梯)": "Tiered Billing (no matching tier)",
"输入 Tokens 阶梯": "Input Token Tiers", "输入 Tokens 阶梯": "Input Token Tiers",
"输出 Tokens 阶梯": "Output Token Tiers", "输出 Tokens 阶梯": "Output Token Tiers",
"固定阶梯": "Fixed Tier", "固定阶梯": "Fixed Tier",
......
...@@ -3642,6 +3642,8 @@ ...@@ -3642,6 +3642,8 @@
"默认折叠侧边栏": "Réduire la barre latérale par défaut", "默认折叠侧边栏": "Réduire la barre latérale par défaut",
"默认测试模型": "Modèle de test par défaut", "默认测试模型": "Modèle de test par défaut",
"默认用户消息": "Bonjour", "默认用户消息": "Bonjour",
"默认补全倍率": "Taux de complétion par défaut" "默认补全倍率": "Taux de complétion par défaut",
"阶梯计费(表达式解析失败)": "Facturation par paliers (échec de l'analyse de l'expression)",
"阶梯计费(未匹配到对应阶梯)": "Facturation par paliers (aucun palier correspondant)"
} }
} }
...@@ -3611,6 +3611,8 @@ ...@@ -3611,6 +3611,8 @@
"默认折叠侧边栏": "サイドバーをデフォルトで折りたたむ", "默认折叠侧边栏": "サイドバーをデフォルトで折りたたむ",
"默认测试模型": "デフォルトテストモデル", "默认测试模型": "デフォルトテストモデル",
"默认用户消息": "こんにちは", "默认用户消息": "こんにちは",
"默认补全倍率": "デフォルト補完倍率" "默认补全倍率": "デフォルト補完倍率",
"阶梯计费(表达式解析失败)": "段階課金(式の解析に失敗)",
"阶梯计费(未匹配到对应阶梯)": "段階課金(一致する階層なし)"
} }
} }
...@@ -3662,6 +3662,8 @@ ...@@ -3662,6 +3662,8 @@
"默认折叠侧边栏": "Сворачивать боковую панель по умолчанию", "默认折叠侧边栏": "Сворачивать боковую панель по умолчанию",
"默认测试模型": "Модель для тестирования по умолчанию", "默认测试模型": "Модель для тестирования по умолчанию",
"默认用户消息": "Здравствуйте", "默认用户消息": "Здравствуйте",
"默认补全倍率": "Коэффициент завершения по умолчанию" "默认补全倍率": "Коэффициент завершения по умолчанию",
"阶梯计费(表达式解析失败)": "Многоуровневая тарификация (ошибка разбора выражения)",
"阶梯计费(未匹配到对应阶梯)": "Многоуровневая тарификация (подходящий уровень не найден)"
} }
} }
...@@ -4176,6 +4176,8 @@ ...@@ -4176,6 +4176,8 @@
"默认折叠侧边栏": "Mặc định thu gọn thanh bên", "默认折叠侧边栏": "Mặc định thu gọn thanh bên",
"默认测试模型": "Mô hình kiểm tra mặc định", "默认测试模型": "Mô hình kiểm tra mặc định",
"默认用户消息": "Xin chào", "默认用户消息": "Xin chào",
"默认补全倍率": "Tỷ lệ hoàn thành mặc định" "默认补全倍率": "Tỷ lệ hoàn thành mặc định",
"阶梯计费(表达式解析失败)": "Thanh toán theo bậc (không phân tích được biểu thức)",
"阶梯计费(未匹配到对应阶梯)": "Thanh toán theo bậc (không tìm thấy bậc phù hợp)"
} }
} }
...@@ -3676,6 +3676,8 @@ ...@@ -3676,6 +3676,8 @@
"缓存创建-5分钟 (cc5)": "缓存创建-5分钟 (cc5)", "缓存创建-5分钟 (cc5)": "缓存创建-5分钟 (cc5)",
"缓存创建-1小时 (cc1h)": "缓存创建-1小时 (cc1h)", "缓存创建-1小时 (cc1h)": "缓存创建-1小时 (cc1h)",
"阶梯计费": "阶梯计费", "阶梯计费": "阶梯计费",
"阶梯计费(表达式解析失败)": "阶梯计费(表达式解析失败)",
"阶梯计费(未匹配到对应阶梯)": "阶梯计费(未匹配到对应阶梯)",
"输入 Tokens 阶梯": "输入 Tokens 阶梯", "输入 Tokens 阶梯": "输入 Tokens 阶梯",
"输出 Tokens 阶梯": "输出 Tokens 阶梯", "输出 Tokens 阶梯": "输出 Tokens 阶梯",
"固定阶梯": "固定阶梯", "固定阶梯": "固定阶梯",
......
...@@ -3635,6 +3635,8 @@ ...@@ -3635,6 +3635,8 @@
"默认折叠侧边栏": "預設摺疊側邊欄", "默认折叠侧边栏": "預設摺疊側邊欄",
"默认测试模型": "預設測試模型", "默认测试模型": "預設測試模型",
"默认用户消息": "你好", "默认用户消息": "你好",
"默认补全倍率": "預設補全倍率" "默认补全倍率": "預設補全倍率",
"阶梯计费(表达式解析失败)": "階梯計費(表達式解析失敗)",
"阶梯计费(未匹配到对应阶梯)": "階梯計費(未匹配到對應階梯)"
} }
} }
...@@ -2588,6 +2588,8 @@ ...@@ -2588,6 +2588,8 @@
"关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?", "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?",
"关闭提示": "关闭提示", "关闭提示": "关闭提示",
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。", "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。",
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。" "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。",
"阶梯计费(表达式解析失败)": "阶梯计费(表达式解析失败)",
"阶梯计费(未匹配到对应阶梯)": "阶梯计费(未匹配到对应阶梯)"
} }
} }
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ import {
MATCH_LT, MATCH_LT,
MATCH_RANGE, MATCH_RANGE,
SOURCE_TIME, SOURCE_TIME,
normalizeTierLabel,
parseTiersFromExpr, parseTiersFromExpr,
splitBillingExprAndRequestRules, splitBillingExprAndRequestRules,
tryParseRequestRuleExpr, tryParseRequestRuleExpr,
...@@ -168,6 +169,9 @@ export function DynamicPricingBreakdown({ ...@@ -168,6 +169,9 @@ export function DynamicPricingBreakdown({
const hasTiers = tiers.length > 0 const hasTiers = tiers.length > 0
const hasRules = ruleGroups.length > 0 const hasRules = ruleGroups.length > 0
const normalizedMatchedTierLabel = normalizeTierLabel(
matchedTierLabel ?? undefined
)
if (!expr) return null if (!expr) return null
...@@ -307,9 +311,9 @@ export function DynamicPricingBreakdown({ ...@@ -307,9 +311,9 @@ export function DynamicPricingBreakdown({
{tiers.map((tier, i) => { {tiers.map((tier, i) => {
const condSummary = formatConditionSummary(tier.conditions, t) const condSummary = formatConditionSummary(tier.conditions, t)
const isMatched = const isMatched =
matchedTierLabel != null && normalizedMatchedTierLabel !== '' &&
matchedTierLabel !== '' && normalizeTierLabel(tier.label) ===
tier.label === matchedTierLabel normalizedMatchedTierLabel
return ( return (
<TableRow <TableRow
key={`tier-${i}`} key={`tier-${i}`}
......
...@@ -286,6 +286,15 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] { ...@@ -286,6 +286,15 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
} }
} }
export function normalizeTierLabel(label: string | undefined): string {
if (!label) return ''
return label
.replace(/<[==]?|≤|<[==]?/g, '<')
.replace(/>[==]?|≥|>[==]?/g, '>')
.replace(/\s+/g, '')
.toLowerCase()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Request rule parser // Request rule parser
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
......
...@@ -115,51 +115,59 @@ function buildDetailSegments( ...@@ -115,51 +115,59 @@ function buildDetailSegments(
const text = prices.join(' / ') const text = prices.join(' / ')
return showUnit ? `${text}/M` : text return showUnit ? `${text}/M` : text
} }
const isTieredExpr = other.billing_mode === 'tiered_expr'
const tieredSummary = getTieredBillingSummary(other) const tieredSummary = getTieredBillingSummary(other)
if (tieredSummary) { if (isTieredExpr) {
const baseEntries = tieredSummary.priceEntries if (tieredSummary) {
.filter((entry) => ['inputPrice', 'outputPrice'].includes(entry.field)) const baseEntries = tieredSummary.priceEntries
.map((entry) => formatPriceCompact(entry.price)) .filter((entry) => ['inputPrice', 'outputPrice'].includes(entry.field))
if (baseEntries.length > 0) { .map((entry) => formatPriceCompact(entry.price))
const tierLabel = tieredSummary.tier.label || t('Default') if (baseEntries.length > 0) {
segments.push({ const tierLabel = tieredSummary.tier.label || t('Default')
text: `${tierLabel} · ${formatPriceList(baseEntries, true)}`, segments.push({
}) text: `${tierLabel} · ${formatPriceList(baseEntries, true)}`,
} })
}
const cacheEntries = tieredSummary.priceEntries
.filter((entry) =>
[
'cacheReadPrice',
'cacheCreatePrice',
'cacheCreate1hPrice',
].includes(entry.field)
)
.map((entry) => {
return formatPriceCompact(entry.price)
})
if (cacheEntries.length > 0) {
segments.push({
text: `${t('Cache')} ${formatPriceList(cacheEntries, false)}`,
muted: true,
})
}
const otherEntries = tieredSummary.priceEntries const cacheEntries = tieredSummary.priceEntries
.filter( .filter((entry) =>
(entry) => [
![
'inputPrice',
'outputPrice',
'cacheReadPrice', 'cacheReadPrice',
'cacheCreatePrice', 'cacheCreatePrice',
'cacheCreate1hPrice', 'cacheCreate1hPrice',
].includes(entry.field) ].includes(entry.field)
) )
.map((entry) => `${t(entry.shortLabel)} ${formatPrice(entry.price)}`) .map((entry) => {
if (otherEntries.length > 0) { return formatPriceCompact(entry.price)
})
if (cacheEntries.length > 0) {
segments.push({
text: `${t('Cache')} ${formatPriceList(cacheEntries, false)}`,
muted: true,
})
}
const otherEntries = tieredSummary.priceEntries
.filter(
(entry) =>
![
'inputPrice',
'outputPrice',
'cacheReadPrice',
'cacheCreatePrice',
'cacheCreate1hPrice',
].includes(entry.field)
)
.map((entry) => `${t(entry.shortLabel)} ${formatPrice(entry.price)}`)
if (otherEntries.length > 0) {
segments.push({
text: otherEntries.join(' · '),
muted: true,
})
}
} else {
segments.push({ segments.push({
text: otherEntries.join(' · '), text: `${t('Dynamic Pricing')} · ${t('No matching results')}`,
muted: true, muted: true,
}) })
} }
......
...@@ -126,6 +126,7 @@ function BillingBreakdown(props: { ...@@ -126,6 +126,7 @@ function BillingBreakdown(props: {
const { log, other, isAdmin } = props const { log, other, isAdmin } = props
const isPerCall = isPerCallBilling(other.model_price) const isPerCall = isPerCallBilling(other.model_price)
const isClaude = other.claude === true const isClaude = other.claude === true
const isTieredExpr = other.billing_mode === 'tiered_expr'
const tieredSummary = getTieredBillingSummary(other) const tieredSummary = getTieredBillingSummary(other)
const rows: Array<{ label: string; value: string }> = [] const rows: Array<{ label: string; value: string }> = []
...@@ -133,21 +134,28 @@ function BillingBreakdown(props: { ...@@ -133,21 +134,28 @@ function BillingBreakdown(props: {
const fmtPrice = (usd: number) => formatBillingCurrencyFromUSD(usd, priceOpts) const fmtPrice = (usd: number) => formatBillingCurrencyFromUSD(usd, priceOpts)
const baseInputUSD = other.model_ratio != null ? other.model_ratio * 2.0 : 0 const baseInputUSD = other.model_ratio != null ? other.model_ratio * 2.0 : 0
if (tieredSummary) { if (isTieredExpr) {
rows.push({ rows.push({
label: t('Billing Mode'), label: t('Billing Mode'),
value: t('Dynamic Pricing'), value: t('Dynamic Pricing'),
}) })
if (tieredSummary.tier.label) { if (tieredSummary) {
if (tieredSummary.tier.label) {
rows.push({
label: t('Matched Tier'),
value: tieredSummary.tier.label,
})
}
for (const entry of tieredSummary.priceEntries) {
rows.push({
label: t(entry.shortLabel),
value: `${fmtPrice(entry.price)}/M`,
})
}
} else {
rows.push({ rows.push({
label: t('Matched Tier'), label: t('Matched Tier'),
value: tieredSummary.tier.label, value: t('No matching results'),
})
}
for (const entry of tieredSummary.priceEntries) {
rows.push({
label: t(entry.shortLabel),
value: `${fmtPrice(entry.price)}/M`,
}) })
} }
} else if (isPerCall) { } else if (isPerCall) {
...@@ -184,7 +192,7 @@ function BillingBreakdown(props: { ...@@ -184,7 +192,7 @@ function BillingBreakdown(props: {
}) })
} }
if (!tieredSummary && isClaude && hasAnyCacheTokens(other)) { if (!isTieredExpr && isClaude && hasAnyCacheTokens(other)) {
if (other.cache_ratio != null && other.cache_ratio !== 1) { if (other.cache_ratio != null && other.cache_ratio !== 1) {
rows.push({ rows.push({
label: t('Cache Read'), label: t('Cache Read'),
...@@ -220,7 +228,7 @@ function BillingBreakdown(props: { ...@@ -220,7 +228,7 @@ function BillingBreakdown(props: {
} }
} }
if (!tieredSummary) { if (!isTieredExpr) {
if (other.audio_ratio != null && other.audio_ratio !== 1) { if (other.audio_ratio != null && other.audio_ratio !== 1) {
rows.push({ rows.push({
label: t('Audio input'), label: t('Audio input'),
......
import type { StatusBadgeProps } from '@/components/status-badge' import type { StatusBadgeProps } from '@/components/status-badge'
import { import {
BILLING_PRICING_VARS, BILLING_PRICING_VARS,
normalizeTierLabel,
parseTiersFromExpr, parseTiersFromExpr,
type ParsedTier, type ParsedTier,
} from '@/features/pricing/lib/billing-expr' } from '@/features/pricing/lib/billing-expr'
import type { UsageLog } from '../data/schema' import type { UsageLog } from '../data/schema'
import type { LogOtherData } from '../types' import type { LogOtherData } from '../types'
export { normalizeTierLabel }
const PARAM_OVERRIDE_ACTION_MAP: Record<string, string> = { const PARAM_OVERRIDE_ACTION_MAP: Record<string, string> = {
set: 'Set', set: 'Set',
delete: 'Delete', delete: 'Delete',
...@@ -36,8 +39,8 @@ const PARAM_OVERRIDE_ACTION_MAP: Record<string, string> = { ...@@ -36,8 +39,8 @@ const PARAM_OVERRIDE_ACTION_MAP: Record<string, string> = {
* Get localized label for a param override action * Get localized label for a param override action
*/ */
export function getParamOverrideActionLabel( export function getParamOverrideActionLabel(
action: string, action: string,
t: (key: string) => string t: (key: string) => string
): string { ): string {
const key = PARAM_OVERRIDE_ACTION_MAP[action.toLowerCase()] const key = PARAM_OVERRIDE_ACTION_MAP[action.toLowerCase()]
return key ? t(key) : action return key ? t(key) : action
...@@ -47,7 +50,7 @@ export function getParamOverrideActionLabel( ...@@ -47,7 +50,7 @@ export function getParamOverrideActionLabel(
* Parse a param override audit line into action and content * Parse a param override audit line into action and content
*/ */
export function parseAuditLine( export function parseAuditLine(
line: string line: string
): { action: string; content: string } | null { ): { action: string; content: string } | null {
if (typeof line !== 'string') return null if (typeof line !== 'string') return null
const firstSpace = line.indexOf(' ') const firstSpace = line.indexOf(' ')
...@@ -64,9 +67,9 @@ export function parseAuditLine( ...@@ -64,9 +67,9 @@ export function parseAuditLine(
export function isViolationFeeLog(other: LogOtherData | null): boolean { export function isViolationFeeLog(other: LogOtherData | null): boolean {
if (!other) return false if (!other) return false
return ( return (
other.violation_fee === true || other.violation_fee === true ||
Boolean(other.violation_fee_code) || Boolean(other.violation_fee_code) ||
Boolean(other.violation_fee_marker) Boolean(other.violation_fee_marker)
) )
} }
...@@ -88,7 +91,7 @@ export function parseLogOther(other: string): LogOtherData | null { ...@@ -88,7 +91,7 @@ export function parseLogOther(other: string): LogOtherData | null {
* Get time color based on duration (in seconds) * Get time color based on duration (in seconds)
*/ */
export function getTimeColor( export function getTimeColor(
seconds: number seconds: number
): 'success' | 'warning' | 'danger' { ): 'success' | 'warning' | 'danger' {
if (seconds < 10) return 'success' if (seconds < 10) return 'success'
if (seconds < 30) return 'warning' if (seconds < 30) return 'warning'
...@@ -99,7 +102,7 @@ export function getTimeColor( ...@@ -99,7 +102,7 @@ export function getTimeColor(
* Get first-response-token color based on latency (in seconds) * Get first-response-token color based on latency (in seconds)
*/ */
export function getFirstResponseTimeColor( export function getFirstResponseTimeColor(
seconds: number seconds: number
): 'success' | 'warning' | 'danger' { ): 'success' | 'warning' | 'danger' {
if (seconds < 5) return 'success' if (seconds < 5) return 'success'
if (seconds < 10) return 'warning' if (seconds < 10) return 'warning'
...@@ -110,7 +113,7 @@ export function getFirstResponseTimeColor( ...@@ -110,7 +113,7 @@ export function getFirstResponseTimeColor(
* Get throughput color based on generated tokens per second * Get throughput color based on generated tokens per second
*/ */
export function getThroughputColor( export function getThroughputColor(
tokensPerSecond: number tokensPerSecond: number
): 'success' | 'warning' | 'danger' { ): 'success' | 'warning' | 'danger' {
if (tokensPerSecond >= 30) return 'success' if (tokensPerSecond >= 30) return 'success'
if (tokensPerSecond >= 15) return 'warning' if (tokensPerSecond >= 15) return 'warning'
...@@ -121,8 +124,8 @@ export function getThroughputColor( ...@@ -121,8 +124,8 @@ export function getThroughputColor(
* Get response color using throughput only when enough output tokens exist. * Get response color using throughput only when enough output tokens exist.
*/ */
export function getResponseTimeColor( export function getResponseTimeColor(
seconds: number, seconds: number,
completionTokens: number completionTokens: number
): 'success' | 'warning' | 'danger' { ): 'success' | 'warning' | 'danger' {
if (completionTokens < 100 || seconds <= 0) return getTimeColor(seconds) if (completionTokens < 100 || seconds <= 0) return getTimeColor(seconds)
return getThroughputColor(completionTokens / seconds) return getThroughputColor(completionTokens / seconds)
...@@ -138,9 +141,9 @@ export function formatModelName(log: UsageLog): { ...@@ -138,9 +141,9 @@ export function formatModelName(log: UsageLog): {
} { } {
const other = parseLogOther(log.other) const other = parseLogOther(log.other)
const isMapped = !!( const isMapped = !!(
other?.is_model_mapped && other?.is_model_mapped &&
other?.upstream_model_name && other?.upstream_model_name &&
other.upstream_model_name !== '' other.upstream_model_name !== ''
) )
return { return {
...@@ -157,7 +160,25 @@ export function formatModelName(log: UsageLog): { ...@@ -157,7 +160,25 @@ export function formatModelName(log: UsageLog): {
export function decodeBillingExprB64(exprB64: string | undefined): string { export function decodeBillingExprB64(exprB64: string | undefined): string {
if (!exprB64) return '' if (!exprB64) return ''
try { try {
return atob(exprB64) const binaryString =
typeof window !== 'undefined'
? window.atob(exprB64)
: Buffer.from(exprB64, 'base64').toString('binary')
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder().decode(bytes)
}
return decodeURIComponent(
Array.prototype.map
.call(bytes, (byte: number) => '%' + byte.toString(16).padStart(2, '0'))
.join('')
)
} catch { } catch {
return '' return ''
} }
...@@ -165,19 +186,21 @@ export function decodeBillingExprB64(exprB64: string | undefined): string { ...@@ -165,19 +186,21 @@ export function decodeBillingExprB64(exprB64: string | undefined): string {
/** /**
* Resolve which parsed tier corresponds to the matched_tier label in a log * Resolve which parsed tier corresponds to the matched_tier label in a log
* entry. Falls back to the first tier when the label is missing or unknown, * entry. Missing or unknown labels do not fall back to another tier because
* which mirrors the behaviour of the classic frontend renderer. * that would display guessed unit prices.
*/ */
export function resolveMatchedTier( export function resolveMatchedTier(
tiers: ParsedTier[], tiers: ParsedTier[],
matchedLabel: string | undefined matchedLabel: string | undefined
): ParsedTier | null { ): ParsedTier | null {
if (tiers.length === 0) return null if (tiers.length === 0) return null
if (matchedLabel) { if (!matchedLabel) return null
const found = tiers.find((tier) => tier.label === matchedLabel) const found = tiers.find((tier) => {
if (found) return found const l1 = normalizeTierLabel(tier.label)
} const l2 = normalizeTierLabel(matchedLabel)
return tiers[0] return l1 === l2 && l1 !== ''
})
return found || null
} }
/** /**
...@@ -197,19 +220,19 @@ export interface TieredBillingSummary { ...@@ -197,19 +220,19 @@ export interface TieredBillingSummary {
* not exercise the cache path (mirrors the classic frontend behaviour). * not exercise the cache path (mirrors the classic frontend behaviour).
*/ */
export function hasAnyCacheTokens( export function hasAnyCacheTokens(
other: LogOtherData | null | undefined other: LogOtherData | null | undefined
): boolean { ): boolean {
if (!other) return false if (!other) return false
return ( return (
(other.cache_tokens || 0) > 0 || (other.cache_tokens || 0) > 0 ||
(other.cache_creation_tokens || 0) > 0 || (other.cache_creation_tokens || 0) > 0 ||
(other.cache_creation_tokens_5m || 0) > 0 || (other.cache_creation_tokens_5m || 0) > 0 ||
(other.cache_creation_tokens_1h || 0) > 0 (other.cache_creation_tokens_1h || 0) > 0
) )
} }
export function getTieredBillingSummary( export function getTieredBillingSummary(
other: LogOtherData | null other: LogOtherData | null
): TieredBillingSummary | null { ): TieredBillingSummary | null {
if (!other || other.billing_mode !== 'tiered_expr') return null if (!other || other.billing_mode !== 'tiered_expr') return null
const exprStr = decodeBillingExprB64(other.expr_b64) const exprStr = decodeBillingExprB64(other.expr_b64)
...@@ -244,16 +267,16 @@ export function getTieredBillingSummary( ...@@ -244,16 +267,16 @@ export function getTieredBillingSummary(
* @param unit - Unit of the timestamps ('seconds' or 'milliseconds') * @param unit - Unit of the timestamps ('seconds' or 'milliseconds')
*/ */
export function formatDuration( export function formatDuration(
submitTime?: number, submitTime?: number,
finishTime?: number, finishTime?: number,
unit: 'seconds' | 'milliseconds' = 'milliseconds' unit: 'seconds' | 'milliseconds' = 'milliseconds'
): { durationSec: number; variant: StatusBadgeProps['variant'] } | null { ): { durationSec: number; variant: StatusBadgeProps['variant'] } | null {
if (!submitTime || !finishTime) return null if (!submitTime || !finishTime) return null
const durationSec = const durationSec =
unit === 'milliseconds' unit === 'milliseconds'
? (finishTime - submitTime) / 1000 ? (finishTime - submitTime) / 1000
: finishTime - submitTime : finishTime - submitTime
return { durationSec, variant: durationSec > 60 ? 'red' : 'green' } return { durationSec, variant: durationSec > 60 ? 'red' : 'green' }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment