feat: implement tiered billing expression evaluation and related functionality

- Added support for tiered billing expressions in the billing system.
- Introduced new types and functions for handling billing expressions, including caching and execution.
- Updated existing billing logic to accommodate tiered billing scenarios.
- Enhanced request handling to support incoming billing expression requests.
- Added tests for tiered billing functionality to ensure correctness.
This commit is contained in:
CaIon
2026-03-16 16:00:22 +08:00
parent a4fd2246ba
commit 91ed4e196a
34 changed files with 4797 additions and 26 deletions
@@ -377,6 +377,43 @@ function renderCompactDetailSummary(summarySegments) {
);
}
function buildTieredBillingSegments(other, t) {
const segments = [
{ text: `${t('阶梯计费')}`, tone: 'primary' },
];
if (other.matched_tier) {
segments.push({
text: `${t('命中档位')}: ${other.matched_tier}`,
tone: 'secondary',
});
}
const groupRatio = other.group_ratio;
if (groupRatio !== undefined && groupRatio !== null) {
segments.push({
text: `${t('分组')} ${formatRatio(groupRatio)}x`,
tone: 'secondary',
});
}
if (other.crossed_tier) {
segments.push({
text: `${t('跨阶梯')}: ${t('是')}`,
tone: 'secondary',
});
}
if (other.actual_quota_after_group !== undefined) {
segments.push({
text: `${t('实际额度')}: ${other.actual_quota_after_group}`,
tone: 'secondary',
});
}
return { segments };
}
function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
const other = getLogOther(record.other);
@@ -414,6 +451,10 @@ function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
};
}
if (other?.billing_mode === 'tiered_expr') {
return buildTieredBillingSegments(other, t);
}
return {
segments: other?.claude
? renderModelPriceSimple(
+60
View File
@@ -559,6 +559,66 @@ export const useLogsData = () => {
value: other.reasoning_effort,
});
}
if (other?.billing_mode === 'tiered_expr') {
expandDataLocal.push({
key: t('计费方式'),
value: t('阶梯计费'),
});
if (other?.group_ratio !== undefined) {
const gr = other.group_ratio;
expandDataLocal.push({
key: t('分组倍率'),
value: typeof gr === 'number' ? gr.toFixed(4) : String(gr ?? '-'),
});
}
if (other?.rule_version !== undefined) {
expandDataLocal.push({
key: t('规则版本'),
value: String(other.rule_version),
});
}
if (other?.estimated_env) {
expandDataLocal.push({
key: t('预估环境'),
value: `prompt=${other.estimated_env.prompt_tokens ?? 0}, completion=${other.estimated_env.completion_tokens ?? 0}`,
});
}
if (other?.actual_env) {
expandDataLocal.push({
key: t('实际环境'),
value: `prompt=${other.actual_env.prompt_tokens ?? 0}, completion=${other.actual_env.completion_tokens ?? 0}`,
});
}
if (other?.estimated_quota_after_group !== undefined) {
expandDataLocal.push({
key: t('预估额度'),
value: String(other.estimated_quota_after_group),
});
}
if (other?.actual_quota_after_group !== undefined) {
expandDataLocal.push({
key: t('实际额度'),
value: String(other.actual_quota_after_group),
});
}
expandDataLocal.push({
key: t('跨阶梯'),
value: other?.crossed_tier ? t('是') : t('否'),
});
if (Array.isArray(other?.breakdown) && other.breakdown.length > 0) {
const breakdownText = other.breakdown.map((item, idx) =>
`[${idx}] ${item.token_type} | tokens=${item.tokens_in_tier} | cost=${item.unit_cost} | flat=${item.flat_fee} | sub=${item.subtotal}`
).join('\n');
expandDataLocal.push({
key: t('计费明细'),
value: (
<div style={{ whiteSpace: 'pre-line', fontFamily: 'monospace', fontSize: 12 }}>
{breakdownText}
</div>
),
});
}
}
}
if (logs[i].type === 6) {
if (other?.task_id) {
+113 -1
View File
@@ -3256,6 +3256,20 @@
"补全价格": "Completion Price",
"缓存读取价格": "Input Cache Read Price",
"缓存创建价格": "Input Cache Creation Price",
"缓存创建价格-5分钟": "Cache Creation Price (5-min)",
"缓存创建价格-1小时": "Cache Creation Price (1-hour)",
"缓存创建价格(5分钟)": "Cache Creation Price (5-min)",
"缓存创建价格(1小时)": "Cache Creation Price (1-hour)",
"分时缓存 (Claude)": "Timed Cache (Claude)",
"通用缓存": "Generic Cache",
"缓存读取": "Cache Read",
"缓存创建": "Cache Creation",
"缓存创建-5分钟": "Cache Creation (5-min)",
"缓存创建-1小时": "Cache Creation (1-hour)",
"缓存读取 Token (cr)": "Cache Read Tokens (cr)",
"缓存创建 Token (cc)": "Cache Creation Tokens (cc)",
"缓存创建-5分钟 (cc5)": "Cache Creation-5min (cc5)",
"缓存创建-1小时 (cc1h)": "Cache Creation-1hour (cc1h)",
"图片输入价格": "Image Input Price",
"音频输入价格": "Audio Input Price",
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "Audio input price: {{symbol}}{{price}} / 1M tokens",
@@ -3309,6 +3323,104 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "Input Price: {{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "Output Price {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "Output Price: {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens",
"阶梯计费": "Tiered Billing",
"输入 Tokens 阶梯": "Input Token Tiers",
"输出 Tokens 阶梯": "Output Token Tiers",
"固定阶梯": "Fixed Tier",
"累进阶梯": "Graduated Tier",
"上限": "Up To",
"单价": "Unit Cost",
"固定费": "Flat Fee",
"Expr 预览": "Expression Preview",
"Token 估算器": "Token Estimator",
"预计费用": "Estimated Cost",
"原始额度": "Raw Quota",
"添加阶梯": "Add Tier",
"无限": "Unlimited",
"输入 Token 定价": "Input Token Pricing",
"输出 Token 定价": "Output Token Pricing",
"统一定价": "Flat Rate",
"阶梯累进": "Graduated",
"根据总用量落在哪个档位,所有 Token 都按该档价格计费": "All tokens are charged at the rate of the tier your total usage falls into",
"用量分段计价,每一段各自按对应档位价格计费(类似电费阶梯)": "Usage is charged in segments — each segment at its own tier rate (like utility billing)",
"Token 用量范围": "Token Usage Range",
"所有 Token": "All Tokens",
"前 {{count}} 个": "First {{count}}",
"超过 {{count}} 个": "Over {{count}}",
"第 {{n}} 档": "Tier {{n}}",
"最高档": "Highest Tier",
"此档上限(Token 数)": "Tier Limit (Token Count)",
"每百万 Token 价格": "Price per 1M Tokens",
"进入此档额外收费": "Tier Entry Fee",
"可选,用量达到此档时加收的固定费用": "Optional fixed fee charged when usage reaches this tier",
"添加更多档位": "Add More Tiers",
"输入 Token 数": "Input Tokens",
"输出 Token 数": "Output Tokens",
"输入 Token 数量,查看按当前阶梯配置的预计费用。": "Enter token counts to see the estimated cost with the current tier configuration.",
"开发者": "Developer",
"阶梯计费详情": "Tiered Billing Details",
"预估环境": "Estimated Env",
"实际环境": "Actual Env",
"预估额度": "Estimated Quota",
"实际额度": "Actual Quota",
"跨阶梯": "Crossed Tier",
"是": "Yes",
"否": "No",
"计费明细": "Billing Breakdown",
"阶梯序号": "Tier #",
"Token 类型": "Token Type",
"阶梯内 Token 数": "Tokens in Tier",
"小计": "Subtotal",
"输入": "Input",
"输出": "Output",
"阶梯配置摘要": "Tier Config Summary",
"输入阶梯": "Input Tiers",
"档位名称": "Tier Name",
"用量范围": "Usage Range",
"输入 Token": "Input Token",
"输出 Token": "Output Token",
"阶梯判断依据": "Tier Criterion",
"根据哪个维度的 Token 数量决定落在哪一档": "Determines which tier to apply based on this dimension's token count",
"输入 Token 数 (p)": "Input Tokens (p)",
"输出 Token 数 (c)": "Output Tokens (c)",
"变量": "Variables",
"函数": "Functions",
"输入计费表达式...": "Enter billing expression...",
"表达式编辑": "Expression Editor",
"表达式错误": "Expression Error",
"命中档位": "Matched Tier",
"档": "tier(s)",
"输入 Token 数量,查看按当前配置的预计费用。": "Enter token counts to see the estimated cost.",
"输入 Token 数量,查看按当前配置的预计费用(不含分组倍率)。": "Enter token counts to see the estimated cost (before group ratio).",
"条件": "Condition",
"添加条件": "Add Condition",
"无条件(兜底档)": "No condition (fallback)",
"兜底档": "Fallback",
"预设模板": "Presets",
"每个档位可设置 0~2 个条件(对 p 和 c),最后一档为兜底档无需条件。": "Each tier can have 0-2 conditions (on p and c). The last tier is the fallback and needs no condition.",
"输出阶梯": "Output Tiers",
"阶": "tiers",
"规则版本": "Rule Version",
"时间条件": "Time condition",
"小时": "Hour",
"分钟": "Minute",
"星期": "Weekday",
"月份": "Month",
"日期": "Day",
"时区": "Timezone",
"跨夜范围": "Cross-midnight range",
"添加时间规则": "Add time rule",
"起": "From",
"止": "To",
"值": "Value",
"添加条件组": "Add condition group",
"添加条件": "Add condition",
"添加时间条件": "Add time condition",
"同时满足": "all must match",
"新年促销": "New Year promo",
"第 {{n}} 组": "Group {{n}}",
"0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=Sun 1=Mon 2=Tue 3=Wed 4=Thu 5=Fri 6=Sat",
"1=一月 ... 12=十二月": "1=Jan ... 12=Dec"
}
}
+111 -1
View File
@@ -2858,6 +2858,20 @@
"补全价格": "补全价格",
"缓存读取价格": "缓存读取价格",
"缓存创建价格": "缓存创建价格",
"缓存创建价格-5分钟": "缓存创建价格-5分钟",
"缓存创建价格-1小时": "缓存创建价格-1小时",
"缓存创建价格(5分钟)": "缓存创建价格(5分钟)",
"缓存创建价格(1小时)": "缓存创建价格(1小时)",
"分时缓存 (Claude)": "分时缓存 (Claude)",
"通用缓存": "通用缓存",
"缓存读取": "缓存读取",
"缓存创建": "缓存创建",
"缓存创建-5分钟": "缓存创建-5分钟",
"缓存创建-1小时": "缓存创建-1小时",
"缓存读取 Token (cr)": "缓存读取 Token (cr)",
"缓存创建 Token (cc)": "缓存创建 Token (cc)",
"缓存创建-5分钟 (cc5)": "缓存创建-5分钟 (cc5)",
"缓存创建-1小时 (cc1h)": "缓存创建-1小时 (cc1h)",
"图片输入价格": "图片输入价格",
"音频输入价格": "音频输入价格",
"音频补全价格": "音频补全价格",
@@ -2938,6 +2952,102 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "输入价格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "输出价格 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "输出价格:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens",
"阶梯计费": "阶梯计费",
"输入 Tokens 阶梯": "输入 Tokens 阶梯",
"输出 Tokens 阶梯": "输出 Tokens 阶梯",
"固定阶梯": "固定阶梯",
"累进阶梯": "累进阶梯",
"上限": "上限",
"单价": "单价",
"固定费": "固定费",
"Expr 预览": "Expr 预览",
"Token 估算器": "Token 估算器",
"预计费用": "预计费用",
"添加阶梯": "添加阶梯",
"无限": "无限",
"输入 Token 定价": "输入 Token 定价",
"输出 Token 定价": "输出 Token 定价",
"统一定价": "统一定价",
"阶梯累进": "阶梯累进",
"根据总用量落在哪个档位,所有 Token 都按该档价格计费": "根据总用量落在哪个档位,所有 Token 都按该档价格计费",
"用量分段计价,每一段各自按对应档位价格计费(类似电费阶梯)": "用量分段计价,每一段各自按对应档位价格计费(类似电费阶梯)",
"Token 用量范围": "Token 用量范围",
"所有 Token": "所有 Token",
"前 {{count}} 个": "前 {{count}} 个",
"超过 {{count}} 个": "超过 {{count}} 个",
"第 {{n}} 档": "第 {{n}} 档",
"最高档": "最高档",
"此档上限(Token 数)": "此档上限(Token 数)",
"每百万 Token 价格": "每百万 Token 价格",
"进入此档额外收费": "进入此档额外收费",
"可选,用量达到此档时加收的固定费用": "可选,用量达到此档时加收的固定费用",
"添加更多档位": "添加更多档位",
"输入 Token 数": "输入 Token 数",
"输出 Token 数": "输出 Token 数",
"输入 Token 数量,查看按当前阶梯配置的预计费用。": "输入 Token 数量,查看按当前阶梯配置的预计费用。",
"开发者": "开发者",
"阶梯计费详情": "阶梯计费详情",
"预估环境": "预估环境",
"实际环境": "实际环境",
"预估额度": "预估额度",
"实际额度": "实际额度",
"跨阶梯": "跨阶梯",
"是": "是",
"否": "否",
"计费明细": "计费明细",
"阶梯序号": "阶梯序号",
"Token 类型": "Token 类型",
"阶梯内 Token 数": "阶梯内 Token 数",
"小计": "小计",
"输入": "输入",
"档位标签": "档位标签",
"用量范围": "用量范围",
"输入 Token": "输入 Token",
"输出 Token": "输出 Token",
"阶梯判断依据": "阶梯判断依据",
"根据哪个维度的 Token 数量决定落在哪一档": "根据哪个维度的 Token 数量决定落在哪一档",
"输入 Token 数 (p)": "输入 Token 数 (p)",
"输出 Token 数 (c)": "输出 Token 数 (c)",
"变量": "变量",
"函数": "函数",
"输入计费表达式...": "输入计费表达式...",
"表达式编辑": "表达式编辑",
"表达式错误": "表达式错误",
"命中档位": "命中档位",
"档": "档",
"输入 Token 数量,查看按当前配置的预计费用。": "输入 Token 数量,查看按当前配置的预计费用。",
"条件": "条件",
"添加条件": "添加条件",
"无条件(兜底档)": "无条件(兜底档)",
"兜底档": "兜底档",
"预设模板": "预设模板",
"每个档位可设置 0~2 个条件(对 p 和 c),最后一档为兜底档无需条件。": "每个档位可设置 0~2 个条件(对 p 和 c),最后一档为兜底档无需条件。",
"输出": "输出",
"阶梯配置摘要": "阶梯配置摘要",
"输入阶梯": "输入阶梯",
"输出阶梯": "输出阶梯",
"阶": "阶",
"规则版本": "规则版本",
"时间条件": "时间条件",
"小时": "小时",
"分钟": "分钟",
"星期": "星期",
"月份": "月份",
"日期": "日期",
"时区": "时区",
"跨夜范围": "跨夜范围",
"添加时间规则": "添加时间规则",
"起": "起",
"止": "止",
"值": "值",
"添加条件组": "添加条件组",
"添加条件": "添加条件",
"添加时间条件": "添加时间条件",
"同时满足": "同时满足",
"新年促销": "新年促销",
"第 {{n}} 组": "第 {{n}} 组",
"0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六",
"1=一月 ... 12=十二月": "1=一月 ... 12=十二月"
}
}
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
Banner,
Button,
@@ -49,6 +49,7 @@ import {
useModelPricingEditorState,
} from '../hooks/useModelPricingEditorState';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import TieredPricingEditor from './TieredPricingEditor';
const { Text } = Typography;
const EMPTY_CANDIDATE_MODEL_NAMES = [];
@@ -123,6 +124,8 @@ export default function ModelPricingEditor({
handleOptionalFieldToggle,
handleNumericFieldChange,
handleBillingModeChange,
handleBillingExprChange,
handleRequestRuleExprChange,
handleSubmit,
addModel,
deleteModel,
@@ -135,6 +138,15 @@ export default function ModelPricingEditor({
filterMode,
});
const getExprModeLabel = useCallback((model) => {
if (model?.billingMode !== 'tiered_expr') {
return '';
}
return (model.billingExpr || '').includes('tier(')
? t('阶梯计费')
: t('表达式计费');
}, [t]);
const columns = useMemo(
() => [
{
@@ -175,10 +187,20 @@ export default function ModelPricingEditor({
dataIndex: 'billingMode',
key: 'billingMode',
render: (_, record) => (
<Tag color={record.billingMode === 'per-request' ? 'teal' : 'violet'}>
<Tag
color={
record.billingMode === 'per-request'
? 'teal'
: record.billingMode === 'tiered_expr'
? 'amber'
: 'violet'
}
>
{record.billingMode === 'per-request'
? t('按次计费')
: t('按量计费')}
: record.billingMode === 'tiered_expr'
? getExprModeLabel(record)
: t('按量计费')}
</Tag>
),
},
@@ -208,6 +230,7 @@ export default function ModelPricingEditor({
[
allowDeleteModel,
deleteModel,
getExprModeLabel,
selectedModelName,
selectedModelNames,
setSelectedModelName,
@@ -353,10 +376,20 @@ export default function ModelPricingEditor({
title={selectedModel ? selectedModel.name : t('模型计费编辑器')}
headerExtraContent={
selectedModel ? (
<Tag color='blue'>
<Tag
color={
selectedModel.billingMode === 'per-request'
? 'teal'
: selectedModel.billingMode === 'tiered_expr'
? 'amber'
: 'blue'
}
>
{selectedModel.billingMode === 'per-request'
? t('按次计费')
: t('按量计费')}
: selectedModel.billingMode === 'tiered_expr'
? getExprModeLabel(selectedModel)
: t('按量计费')}
</Tag>
) : null
}
@@ -381,10 +414,11 @@ export default function ModelPricingEditor({
>
<Radio value='per-token'>{t('按量计费')}</Radio>
<Radio value='per-request'>{t('按次计费')}</Radio>
<Radio value='tiered_expr'>{t('表达式/阶梯计费')}</Radio>
</RadioGroup>
<div className='mt-2 text-xs text-gray-500'>
{t(
'这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。',
'普通按量/按次直接填价格就行;如果价格要跟请求参数或请求头联动,请切到表达式/阶梯计费。',
)}
</div>
</div>
@@ -415,6 +449,14 @@ export default function ModelPricingEditor({
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
extraText={t('适合 MJ / 任务类等按次收费模型。')}
/>
) : selectedModel.billingMode === 'tiered_expr' ? (
<TieredPricingEditor
model={selectedModel}
onExprChange={handleBillingExprChange}
requestRuleExpr={selectedModel.requestRuleExpr}
onRequestRuleExprChange={handleRequestRuleExprChange}
t={t}
/>
) : (
<>
<Card
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,443 @@
export const SOURCE_PARAM = 'param';
export const SOURCE_HEADER = 'header';
export const SOURCE_TIME = 'time';
export const MATCH_EQ = 'eq';
export const MATCH_CONTAINS = 'contains';
export const MATCH_GT = 'gt';
export const MATCH_GTE = 'gte';
export const MATCH_LT = 'lt';
export const MATCH_LTE = 'lte';
export const MATCH_EXISTS = 'exists';
export const MATCH_RANGE = 'range';
export const TIME_FUNCS = ['hour', 'minute', 'weekday', 'month', 'day'];
export const COMMON_TIMEZONES = [
{ value: 'Asia/Shanghai', label: 'CST (UTC+8 北京)' },
{ value: 'UTC', label: 'UTC' },
{ value: 'America/New_York', label: 'EST (UTC-5 纽约)' },
{ value: 'America/Los_Angeles', label: 'PST (UTC-8 洛杉矶)' },
{ value: 'America/Chicago', label: 'CST (UTC-6 芝加哥)' },
{ value: 'Europe/London', label: 'GMT (UTC+0 伦敦)' },
{ value: 'Europe/Berlin', label: 'CET (UTC+1 柏林)' },
{ value: 'Asia/Tokyo', label: 'JST (UTC+9 东京)' },
{ value: 'Asia/Singapore', label: 'SGT (UTC+8 新加坡)' },
{ value: 'Asia/Seoul', label: 'KST (UTC+9 首尔)' },
{ value: 'Australia/Sydney', label: 'AEST (UTC+10 悉尼)' },
];
export const NUMERIC_LITERAL_REGEX =
/^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
// ---------------------------------------------------------------------------
// Condition creators (no multiplier — multiplier lives on the group)
// ---------------------------------------------------------------------------
export function createEmptyCondition() {
return { source: SOURCE_PARAM, path: '', mode: MATCH_EQ, value: '' };
}
export function createEmptyTimeCondition() {
return {
source: SOURCE_TIME,
timeFunc: 'hour',
timezone: 'Asia/Shanghai',
mode: MATCH_GTE,
value: '',
rangeStart: '',
rangeEnd: '',
};
}
// ---------------------------------------------------------------------------
// Group creators
// ---------------------------------------------------------------------------
export function createEmptyRuleGroup() {
return { conditions: [createEmptyCondition()], multiplier: '' };
}
export function createEmptyTimeRuleGroup() {
return { conditions: [createEmptyTimeCondition()], multiplier: '' };
}
// Kept for backward compat with old preset format
export function createEmptyRequestRule() {
return { source: SOURCE_PARAM, path: '', mode: MATCH_EQ, value: '', multiplier: '' };
}
export function createEmptyTimeRule() {
return {
source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai',
mode: MATCH_GTE, value: '', rangeStart: '', rangeEnd: '', multiplier: '',
};
}
// ---------------------------------------------------------------------------
// Match options
// ---------------------------------------------------------------------------
export function getRequestRuleMatchOptions(source, t) {
if (source === SOURCE_TIME) {
return [
{ value: MATCH_EQ, label: t('等于') },
{ value: MATCH_GTE, label: t('大于等于') },
{ value: MATCH_LT, label: t('小于') },
{ value: MATCH_RANGE, label: t('跨夜范围') },
];
}
const base = [
{ value: MATCH_EQ, label: t('等于') },
{ value: MATCH_CONTAINS, label: t('包含') },
{ value: MATCH_EXISTS, label: t('存在') },
];
if (source === SOURCE_HEADER) {
return base;
}
return [
...base,
{ value: MATCH_GT, label: t('大于') },
{ value: MATCH_GTE, label: t('大于等于') },
{ value: MATCH_LT, label: t('小于') },
{ value: MATCH_LTE, label: t('小于等于') },
];
}
// ---------------------------------------------------------------------------
// Normalize a single condition
// ---------------------------------------------------------------------------
export function normalizeCondition(cond) {
const source = cond?.source === SOURCE_TIME
? SOURCE_TIME
: cond?.source === SOURCE_HEADER
? SOURCE_HEADER
: SOURCE_PARAM;
if (source === SOURCE_TIME) {
const timeFunc = TIME_FUNCS.includes(cond?.timeFunc) ? cond.timeFunc : 'hour';
const options = getRequestRuleMatchOptions(SOURCE_TIME, (v) => v);
const mode = options.some((item) => item.value === cond?.mode) ? cond.mode : MATCH_GTE;
return {
source: SOURCE_TIME,
timeFunc,
timezone: cond?.timezone || 'Asia/Shanghai',
mode,
value: cond?.value == null ? '' : String(cond.value),
rangeStart: cond?.rangeStart == null ? '' : String(cond.rangeStart),
rangeEnd: cond?.rangeEnd == null ? '' : String(cond.rangeEnd),
};
}
const options = getRequestRuleMatchOptions(source, (v) => v);
const mode = options.some((item) => item.value === cond?.mode) ? cond.mode : MATCH_EQ;
return {
source,
path: cond?.path || '',
mode,
value: cond?.value == null ? '' : String(cond.value),
};
}
// Legacy compat wrapper
export function normalizeRequestRule(rule) {
const base = normalizeCondition(rule);
return { ...base, multiplier: rule?.multiplier == null ? '' : String(rule.multiplier) };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function splitTopLevelMultiply(expr) {
const parts = [];
let start = 0;
let depth = 0;
for (let index = 0; index < expr.length; index += 1) {
const char = expr[index];
if (char === '(') depth += 1;
if (char === ')') depth -= 1;
if (depth === 0 && expr.slice(index, index + 3) === ' * ') {
parts.push(expr.slice(start, index).trim());
start = index + 3;
index += 2;
}
}
parts.push(expr.slice(start).trim());
return parts.filter(Boolean);
}
function splitTopLevelAnd(expr) {
const parts = [];
let start = 0;
let depth = 0;
for (let i = 0; i < expr.length; i += 1) {
const c = expr[i];
if (c === '(') depth += 1;
if (c === ')') depth -= 1;
if (depth === 0 && expr.slice(i, i + 4) === ' && ') {
parts.push(expr.slice(start, i).trim());
start = i + 4;
i += 3;
}
}
parts.push(expr.slice(start).trim());
return parts.filter(Boolean);
}
function parseExprLiteral(raw) {
const text = raw.trim();
if (text === 'true' || text === 'false') return text;
if (NUMERIC_LITERAL_REGEX.test(text)) return text;
try { return JSON.parse(text); } catch { return null; }
}
function buildExprLiteral(mode, value) {
const text = String(value || '').trim();
if (mode === MATCH_CONTAINS) return JSON.stringify(text);
if (text === 'true' || text === 'false') return text;
if (NUMERIC_LITERAL_REGEX.test(text)) return text;
return JSON.stringify(text);
}
// ---------------------------------------------------------------------------
// Build a single condition expression string (no ? mult : 1 wrapper)
// ---------------------------------------------------------------------------
function buildTimeConditionExpr(cond) {
const normalized = normalizeCondition(cond);
const { timeFunc, timezone, mode } = normalized;
const tz = JSON.stringify(timezone);
const fn = `${timeFunc}(${tz})`;
if (mode === MATCH_RANGE) {
const s = normalized.rangeStart.trim();
const e = normalized.rangeEnd.trim();
if (!NUMERIC_LITERAL_REGEX.test(s) || !NUMERIC_LITERAL_REGEX.test(e)) return '';
return `${fn} >= ${s} || ${fn} < ${e}`;
}
const v = normalized.value.trim();
if (!NUMERIC_LITERAL_REGEX.test(v)) return '';
const opMap = { [MATCH_EQ]: '==', [MATCH_GTE]: '>=', [MATCH_LT]: '<' };
return `${fn} ${opMap[mode] || '=='} ${v}`;
}
function buildRequestConditionExpr(cond) {
if (cond?.source === SOURCE_TIME) return buildTimeConditionExpr(cond);
const normalized = normalizeCondition(cond);
const path = normalized.path.trim();
if (!path) return '';
const sourceExpr = normalized.source === SOURCE_HEADER
? `header(${JSON.stringify(path)})`
: `param(${JSON.stringify(path)})`;
switch (normalized.mode) {
case MATCH_EXISTS:
return normalized.source === SOURCE_HEADER
? `${sourceExpr} != ""`
: `${sourceExpr} != nil`;
case MATCH_CONTAINS:
return normalized.source === SOURCE_HEADER
? `has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`
: `${sourceExpr} != nil && has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`;
case MATCH_GT: case MATCH_GTE: case MATCH_LT: case MATCH_LTE: {
const opMap = { [MATCH_GT]: '>', [MATCH_GTE]: '>=', [MATCH_LT]: '<', [MATCH_LTE]: '<=' };
if (!NUMERIC_LITERAL_REGEX.test(String(normalized.value).trim())) return '';
return `${sourceExpr} != nil && ${sourceExpr} ${opMap[normalized.mode]} ${String(normalized.value).trim()}`;
}
case MATCH_EQ:
default:
return `${sourceExpr} == ${buildExprLiteral(normalized.mode, normalized.value)}`;
}
}
// ---------------------------------------------------------------------------
// Build a group factor: (cond1 && cond2 ? mult : 1)
// ---------------------------------------------------------------------------
function buildRuleGroupFactor(group) {
const multiplier = (group.multiplier || '').trim();
if (!NUMERIC_LITERAL_REGEX.test(multiplier)) return '';
const condExprs = (group.conditions || [])
.map(buildRequestConditionExpr)
.filter(Boolean);
if (condExprs.length === 0) return '';
const combined = condExprs.length === 1
? condExprs[0]
: condExprs.map((e) => (e.includes(' || ') ? `(${e})` : e)).join(' && ');
return `(${combined} ? ${multiplier} : 1)`;
}
export function buildRequestRuleExpr(groups) {
return (groups || []).map(buildRuleGroupFactor).filter(Boolean).join(' * ');
}
// ---------------------------------------------------------------------------
// Parse a single condition from an expression fragment
// ---------------------------------------------------------------------------
function tryParseTimeCondition(expr) {
// Range: hour("tz") >= s || hour("tz") < e
let m = expr.match(
/^(hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)$/,
);
if (m) {
return {
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
mode: MATCH_RANGE, value: '', rangeStart: m[3], rangeEnd: m[4],
};
}
// Wrapped range: (hour("tz") >= s || hour("tz") < e)
m = expr.match(
/^\((hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)\)$/,
);
if (m) {
return {
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
mode: MATCH_RANGE, value: '', rangeStart: m[3], rangeEnd: m[4],
};
}
// Simple: hour("tz") op value
m = expr.match(
/^(hour|minute|weekday|month|day)\("([^"]+)"\) (==|>=|<) ([\d.eE+-]+)$/,
);
if (m) {
const opMap = { '==': MATCH_EQ, '>=': MATCH_GTE, '<': MATCH_LT };
return {
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
mode: opMap[m[3]] || MATCH_EQ, value: m[4], rangeStart: '', rangeEnd: '',
};
}
return null;
}
function tryParseRequestCondition(expr) {
const tc = tryParseTimeCondition(expr);
if (tc) return tc;
let m = expr.match(/^header\("([^"]+)"\) != ""$/);
if (m) return { source: SOURCE_HEADER, path: m[1], mode: MATCH_EXISTS, value: '' };
m = expr.match(/^param\("([^"]+)"\) != nil$/);
if (m) return { source: SOURCE_PARAM, path: m[1], mode: MATCH_EXISTS, value: '' };
m = expr.match(/^has\(header\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/);
if (m) return { source: SOURCE_HEADER, path: m[1], mode: MATCH_CONTAINS, value: JSON.parse(m[2]) };
m = expr.match(/^param\("([^"]+)"\) != nil && has\(param\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/);
if (m && m[1] === m[2]) return { source: SOURCE_PARAM, path: m[1], mode: MATCH_CONTAINS, value: JSON.parse(m[3]) };
m = expr.match(/^param\("([^"]+)"\) != nil && param\("([^"]+)"\) (>|>=|<|<=) ([\d.eE+-]+)$/);
if (m && m[1] === m[2]) {
const opMap = { '>': MATCH_GT, '>=': MATCH_GTE, '<': MATCH_LT, '<=': MATCH_LTE };
return { source: SOURCE_PARAM, path: m[1], mode: opMap[m[3]], value: m[4] };
}
m = expr.match(/^(param|header)\("([^"]+)"\) == (.+)$/);
if (m) {
const parsedValue = parseExprLiteral(m[3]);
if (parsedValue === null) return null;
return { source: m[1], path: m[2], mode: MATCH_EQ, value: String(parsedValue) };
}
return null;
}
// ---------------------------------------------------------------------------
// Parse a group factor: (cond1 && cond2 ? mult : 1)
// ---------------------------------------------------------------------------
function tryParseRuleGroupFactor(part) {
// Must be wrapped in ( ... ? mult : 1)
const m = part.match(/^\((.+) \? ([\d.eE+-]+) : 1\)$/s);
if (!m) return null;
const conditionStr = m[1];
const multiplier = m[2];
const andParts = splitTopLevelAnd(conditionStr);
const conditions = [];
for (const ap of andParts) {
const cond = tryParseRequestCondition(ap.trim());
if (!cond) return null;
conditions.push(normalizeCondition(cond));
}
if (conditions.length === 0) return null;
return { conditions, multiplier };
}
export function tryParseRequestRuleExpr(expr) {
const trimmed = (expr || '').trim();
if (!trimmed) return [];
const parts = splitTopLevelMultiply(trimmed);
const groups = [];
for (const part of parts) {
const group = tryParseRuleGroupFactor(part);
if (!group) return null;
groups.push(group);
}
return groups;
}
// ---------------------------------------------------------------------------
// Combine / split billing expr and request rules
// ---------------------------------------------------------------------------
function hasFullOuterParens(expr) {
if (!expr.startsWith('(') || !expr.endsWith(')')) return false;
let depth = 0;
for (let i = 0; i < expr.length; i += 1) {
if (expr[i] === '(') depth += 1;
if (expr[i] === ')') depth -= 1;
if (depth === 0 && i < expr.length - 1) return false;
}
return depth === 0;
}
export function unwrapOuterParens(expr) {
let current = (expr || '').trim();
while (hasFullOuterParens(current)) {
current = current.slice(1, -1).trim();
}
return current;
}
export function combineBillingExpr(baseExpr, requestRuleExpr) {
const base = (baseExpr || '').trim();
const rules = (requestRuleExpr || '').trim();
if (!base) return '';
if (!rules) return base;
return `(${base}) * ${rules}`;
}
export function splitBillingExprAndRequestRules(expr) {
const trimmed = (expr || '').trim();
if (!trimmed) return { billingExpr: '', requestRuleExpr: '' };
const parts = splitTopLevelMultiply(trimmed);
if (parts.length <= 1) return { billingExpr: trimmed, requestRuleExpr: '' };
const ruleParts = [];
const baseParts = [];
parts.forEach((part) => {
if (tryParseRequestRuleExpr(part) !== null && tryParseRequestRuleExpr(part).length > 0) {
ruleParts.push(part);
} else {
baseParts.push(part);
}
});
if (ruleParts.length === 0 || baseParts.length !== 1) {
return { billingExpr: trimmed, requestRuleExpr: '' };
}
return {
billingExpr: unwrapOuterParens(baseParts[0]),
requestRuleExpr: ruleParts.join(' * '),
};
}
@@ -1,5 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { API, showError, showSuccess } from '../../../../helpers';
import {
combineBillingExpr,
splitBillingExprAndRequestRules,
} from '../components/requestRuleExpr';
export const PAGE_SIZE = 10;
export const PRICE_SUFFIX = '$/1M tokens';
@@ -18,6 +22,8 @@ const EMPTY_MODEL = {
imagePrice: '',
audioInputPrice: '',
audioOutputPrice: '',
billingExpr: '',
requestRuleExpr: '',
rawRatios: {
modelRatio: '',
completionRatio: '',
@@ -98,6 +104,22 @@ const normalizeCompletionRatioMeta = (rawMeta) => {
};
const buildModelState = (name, sourceMaps) => {
const billingMode = sourceMaps.ModelBillingMode?.[name];
if (billingMode === 'tiered_expr') {
const fullBillingExpr = sourceMaps.ModelBillingExpr?.[name] || '';
const { billingExpr, requestRuleExpr } =
splitBillingExprAndRequestRules(fullBillingExpr);
return {
...EMPTY_MODEL,
name,
billingMode: 'tiered_expr',
billingExpr,
requestRuleExpr,
rawRatios: { ...EMPTY_MODEL.rawRatios },
hasConflict: false,
};
}
const modelRatio = toNumericString(sourceMaps.ModelRatio[name]);
const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]);
const completionRatioMeta = normalizeCompletionRatioMeta(
@@ -159,6 +181,7 @@ const buildModelState = (name, sourceMaps) => {
toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio)
? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio))
: '',
requestRuleExpr: '',
rawRatios: {
modelRatio,
completionRatio,
@@ -183,12 +206,16 @@ const buildModelState = (name, sourceMaps) => {
};
export const isBasePricingUnset = (model) =>
model.billingMode !== 'tiered_expr' &&
!hasValue(model.fixedPrice) && !hasValue(model.inputPrice);
export const getModelWarnings = (model, t) => {
if (!model) {
return [];
}
if (model.billingMode === 'tiered_expr') {
return [];
}
const warnings = [];
const hasDerivedPricing = [
model.inputPrice,
@@ -244,8 +271,22 @@ export const getModelWarnings = (model, t) => {
};
export const buildSummaryText = (model, t) => {
const requestRuleSuffix =
model.billingMode === 'tiered_expr' && model.requestRuleExpr
? `${t('请求规则')}`
: '';
if (model.billingMode === 'tiered_expr') {
const expr = model.billingExpr;
if (!expr) return `${t('表达式计费')}${requestRuleSuffix}`;
const tierCount = (expr.match(/tier\(/g) || []).length;
if (tierCount === 0) {
return `${t('表达式计费')}${requestRuleSuffix}`;
}
return `${t('阶梯计费')} (${tierCount} ${t('档')})${requestRuleSuffix}`;
}
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
return `${t('按次')} $${model.fixedPrice} / ${t('次')}`;
return `${t('按次')} $${model.fixedPrice} / ${t('次')}${requestRuleSuffix}`;
}
if (hasValue(model.inputPrice)) {
@@ -259,10 +300,10 @@ export const buildSummaryText = (model, t) => {
].filter(hasValue).length;
const extraLabel =
extraCount > 0 ? `${t('额外价格项')} ${extraCount}` : '';
return `${t('输入')} $${model.inputPrice}${extraLabel}`;
return `${t('输入')} $${model.inputPrice}${extraLabel}${requestRuleSuffix}`;
}
return t('未设置价格');
return `${t('未设置价格')}${requestRuleSuffix}`;
};
export const buildOptionalFieldToggles = (model) => ({
@@ -395,20 +436,53 @@ const serializeModel = (model, t) => {
export const buildPreviewRows = (model, t) => {
if (!model) return [];
const finalBillingExpr = combineBillingExpr(
model.billingExpr,
model.requestRuleExpr,
);
if (model.billingMode === 'tiered_expr') {
const rows = [
{
key: 'BillingMode',
label: 'ModelBillingMode',
value: 'tiered_expr',
},
];
if (finalBillingExpr) {
const tierCount = (model.billingExpr.match(/tier\(/g) || []).length;
rows.push({
key: 'BillingExpr',
label: 'ModelBillingExpr',
value:
tierCount > 0
? `${tierCount} ${t('档')}${
finalBillingExpr.length > 60
? finalBillingExpr.slice(0, 60) + '...'
: finalBillingExpr
}`
: finalBillingExpr.length > 60
? finalBillingExpr.slice(0, 60) + '...'
: finalBillingExpr,
});
}
return rows;
}
if (model.billingMode === 'per-request') {
return [
const rows = [
{
key: 'ModelPrice',
label: 'ModelPrice',
value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'),
},
];
return rows;
}
const inputPrice = toNumberOrNull(model.inputPrice);
if (inputPrice === null) {
return [
const rows = [
{
key: 'ModelRatio',
label: 'ModelRatio',
@@ -459,6 +533,7 @@ export const buildPreviewRows = (model, t) => {
: t('空'),
},
];
return rows;
}
const completionPrice = toNumberOrNull(model.completionPrice);
@@ -468,7 +543,7 @@ export const buildPreviewRows = (model, t) => {
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
return [
const rows = [
{
key: 'ModelRatio',
label: 'ModelRatio',
@@ -522,6 +597,7 @@ export const buildPreviewRows = (model, t) => {
: t('空'),
},
];
return rows;
};
export function useModelPricingEditorState({
@@ -552,6 +628,8 @@ export function useModelPricingEditorState({
ImageRatio: parseOptionJSON(options.ImageRatio),
AudioRatio: parseOptionJSON(options.AudioRatio),
AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio),
ModelBillingMode: parseOptionJSON(options.ModelBillingMode),
ModelBillingExpr: parseOptionJSON(options.ModelBillingExpr),
};
const names = new Set([
@@ -565,6 +643,8 @@ export function useModelPricingEditorState({
...Object.keys(sourceMaps.ImageRatio),
...Object.keys(sourceMaps.AudioRatio),
...Object.keys(sourceMaps.AudioCompletionRatio),
...Object.keys(sourceMaps.ModelBillingMode),
...Object.keys(sourceMaps.ModelBillingExpr),
]);
const nextModels = Array.from(names)
@@ -775,10 +855,29 @@ export function useModelPricingEditorState({
};
const handleBillingModeChange = (value) => {
if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => {
const next = { ...model, billingMode: value };
if (value === 'tiered_expr' && !model.billingExpr) {
next.billingExpr = 'tier("default", p * 0 + c * 0)';
}
return next;
});
};
const handleBillingExprChange = (newExpr) => {
if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => ({
...model,
billingMode: value,
billingExpr: newExpr,
}));
};
const handleRequestRuleExprChange = (newExpr) => {
if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => ({
...model,
requestRuleExpr: newExpr,
}));
};
@@ -854,6 +953,8 @@ export function useModelPricingEditorState({
imagePrice: selectedModel.imagePrice,
audioInputPrice: selectedModel.audioInputPrice,
audioOutputPrice: selectedModel.audioOutputPrice,
billingExpr: selectedModel.billingExpr || '',
requestRuleExpr: selectedModel.requestRuleExpr || '',
};
if (
@@ -915,7 +1016,26 @@ export function useModelPricingEditorState({
AudioCompletionRatio: {},
};
const tieredOutput = {
ModelBillingMode: {},
ModelBillingExpr: {},
};
for (const model of models) {
if (model.billingMode === 'tiered_expr') {
tieredOutput.ModelBillingMode[model.name] = 'tiered_expr';
const finalBillingExpr = combineBillingExpr(
model.billingExpr,
model.requestRuleExpr,
);
if (finalBillingExpr) {
tieredOutput.ModelBillingExpr[model.name] = finalBillingExpr;
}
}
if (model.billingMode === 'tiered_expr') {
continue;
}
const serialized = serializeModel(model, t);
Object.entries(serialized).forEach(([key, value]) => {
if (value !== null) {
@@ -924,12 +1044,20 @@ export function useModelPricingEditorState({
});
}
const requestQueue = Object.entries(output).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
}),
);
const requestQueue = [
...Object.entries(output).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
}),
),
...Object.entries(tieredOutput).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
}),
),
];
const results = await Promise.all(requestQueue);
for (const res of results) {
@@ -970,6 +1098,8 @@ export function useModelPricingEditorState({
handleOptionalFieldToggle,
handleNumericFieldChange,
handleBillingModeChange,
handleBillingExprChange,
handleRequestRuleExprChange,
handleSubmit,
addModel,
deleteModel,