feat: enhance tiered billing functionality and UI components
- Introduced new fields for billing mode and expression in the Pricing model. - Implemented dynamic pricing breakdown component to display tiered billing details. - Updated various components to support and render tiered billing information. - Enhanced pricing calculation logic to accommodate dynamic pricing scenarios. - Added tests for new billing expression functionalities and UI components.
This commit is contained in:
1 parent
91ed4e196a
commit
f0589cc478
23 files changed
+1237
-695
No files matched your search
@@ -324,7 +324,7 @@ export default function ModelPricingEditor({
|
||||
gap: 16,
|
||||
gridTemplateColumns: isMobile
|
||||
? 'minmax(0, 1fr)'
|
||||
: 'minmax(360px, 1.1fr) minmax(420px, 1fr)',
|
||||
: 'minmax(300px, 0.8fr) minmax(480px, 1.2fr)',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
|
||||
@@ -96,11 +96,14 @@ function buildConditionStr(conditions) {
|
||||
.join(' && ');
|
||||
}
|
||||
|
||||
// CACHE_VAR_MAP maps tier data fields to Expr variable names
|
||||
// CACHE_VAR_MAP maps tier data fields to Expr variable names (cache + image + audio)
|
||||
const CACHE_VAR_MAP = [
|
||||
{ field: 'cache_read_unit_cost', exprVar: 'cr' },
|
||||
{ field: 'cache_create_unit_cost', exprVar: 'cc' },
|
||||
{ field: 'cache_create_1h_unit_cost', exprVar: 'cc1h' },
|
||||
{ field: 'image_unit_cost', exprVar: 'img' },
|
||||
{ field: 'audio_input_unit_cost', exprVar: 'ai' },
|
||||
{ field: 'audio_output_unit_cost', exprVar: 'ao' },
|
||||
];
|
||||
|
||||
function getTierCacheMode(tier) {
|
||||
@@ -130,7 +133,7 @@ function createDefaultVisualConfig() {
|
||||
conditions: [],
|
||||
input_unit_cost: 0,
|
||||
output_unit_cost: 0,
|
||||
label: '默认',
|
||||
label: 'base',
|
||||
cache_mode: CACHE_MODE_GENERIC,
|
||||
}),
|
||||
],
|
||||
@@ -270,64 +273,58 @@ function tryParseVisualConfig(exprStr) {
|
||||
function ConditionRow({ cond, onChange, onRemove, t }) {
|
||||
const hint = formatTokenHint(cond.value);
|
||||
return (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
<div style={{
|
||||
marginBottom: 6,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto 1fr auto',
|
||||
gap: '4px 6px',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<Select
|
||||
size='small'
|
||||
value={cond.var || 'p'}
|
||||
onChange={(val) => onChange({ ...cond, var: val })}
|
||||
>
|
||||
<Select
|
||||
size='small'
|
||||
value={cond.var || 'p'}
|
||||
onChange={(val) => onChange({ ...cond, var: val })}
|
||||
style={{ width: 110 }}
|
||||
>
|
||||
{VAR_OPTIONS.map((v) => (
|
||||
<Select.Option key={v.value} value={v.value}>
|
||||
{v.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size='small'
|
||||
value={cond.op || '<'}
|
||||
onChange={(val) => onChange({ ...cond, op: val })}
|
||||
style={{ width: 70 }}
|
||||
>
|
||||
{OPS.map((op) => (
|
||||
<Select.Option key={op} value={op}>
|
||||
{op}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<InputNumber
|
||||
size='small'
|
||||
min={0}
|
||||
value={cond.value ?? ''}
|
||||
onChange={(val) => onChange({ ...cond, value: val })}
|
||||
style={{ flex: 1, minWidth: 100 }}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
onClick={onRemove}
|
||||
/>
|
||||
</div>
|
||||
{VAR_OPTIONS.map((v) => (
|
||||
<Select.Option key={v.value} value={v.value}>
|
||||
{v.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size='small'
|
||||
value={cond.op || '<'}
|
||||
onChange={(val) => onChange({ ...cond, op: val })}
|
||||
style={{ width: 70 }}
|
||||
>
|
||||
{OPS.map((op) => (
|
||||
<Select.Option key={op} value={op}>
|
||||
{op}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<InputNumber
|
||||
size='small'
|
||||
min={0}
|
||||
value={cond.value ?? ''}
|
||||
onChange={(val) => onChange({ ...cond, value: val })}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
onClick={onRemove}
|
||||
/>
|
||||
{hint ? (
|
||||
<Text
|
||||
size='small'
|
||||
style={{
|
||||
color: 'var(--semi-color-text-3)',
|
||||
marginLeft: 186,
|
||||
display: 'block',
|
||||
marginTop: 1,
|
||||
gridColumn: '3 / 4',
|
||||
}}
|
||||
>
|
||||
{hint}
|
||||
= {hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -388,8 +385,8 @@ const CACHE_FIELDS_GENERIC = [
|
||||
];
|
||||
|
||||
function ExtendedPriceBlock({ tier, index, onUpdate, t }) {
|
||||
const hasAny = [...CACHE_FIELDS_TIMED].some(
|
||||
(f) => Number(tier[f.field]) > 0,
|
||||
const hasAny = [...CACHE_FIELDS_TIMED, 'image_unit_cost', 'audio_input_unit_cost', 'audio_output_unit_cost'].some(
|
||||
(f) => Number(tier[typeof f === 'string' ? f : f.field]) > 0,
|
||||
);
|
||||
const [expanded, setExpanded] = useState(hasAny);
|
||||
const cacheMode = getTierCacheMode(tier);
|
||||
@@ -461,6 +458,37 @@ function ExtendedPriceBlock({ tier, index, onUpdate, t }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 mb-2 mt-3'>
|
||||
{t('图片/音频价格(可选)')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ field: 'image_unit_cost', labelKey: '图片输入价格' },
|
||||
{ field: 'audio_input_unit_cost', labelKey: '音频输入价格' },
|
||||
{ field: 'audio_output_unit_cost', labelKey: '音频补全价格' },
|
||||
].map((cf) => (
|
||||
<div key={cf.field}>
|
||||
<Text
|
||||
size='small'
|
||||
style={{ color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
{t(cf.labelKey)}
|
||||
</Text>
|
||||
<PriceInput
|
||||
unitCost={tier[cf.field]}
|
||||
field={cf.field}
|
||||
index={index}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
@@ -730,73 +758,114 @@ function VisualEditor({ visualConfig, onChange, t }) {
|
||||
// Raw Expr editor with preset templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRESETS = [
|
||||
const PRESET_GROUPS = [
|
||||
{
|
||||
key: 'claude-opus',
|
||||
label: 'Claude Opus 4.6',
|
||||
expr: 'tier("default", p * 2.5 + c * 12.5 + cr * 0.25 + cc * 3.125 + cc1h * 5)',
|
||||
},
|
||||
{
|
||||
key: 'claude-opus-fast',
|
||||
label: 'Claude Opus 4.6 Fast',
|
||||
expr: 'tier("default", p * 2.5 + c * 12.5 + cr * 0.25 + cc * 3.125 + cc1h * 5)',
|
||||
requestRules: [
|
||||
{ conditions: [{ source: SOURCE_HEADER, path: 'anthropic-beta', mode: MATCH_CONTAINS, value: 'fast-mode-2026-02-01' }], multiplier: '6' },
|
||||
group: '固定价格',
|
||||
presets: [
|
||||
{ key: 'flat', label: 'Flat', expr: 'tier("base", p * 1 + c * 2)' },
|
||||
{ key: 'claude-opus', label: 'Claude Opus 4.6', expr: 'tier("base", p * 2.5 + c * 12.5 + cr * 0.25 + cc * 3.125 + cc1h * 5)' },
|
||||
{ key: 'gpt-5.4', label: 'GPT-5.4', expr: 'tier("base", p * 1.25 + c * 5 + cr * 0.125)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'claude-sonnet',
|
||||
label: 'Claude Sonnet 4.5',
|
||||
expr: 'p <= 200000 ? tier("standard", p * 1.5 + c * 7.5 + cr * 0.15 + cc * 1.875 + cc1h * 3) : tier("long_context", p * 3 + c * 11.25 + cr * 0.3 + cc * 3.75 + cc1h * 6)',
|
||||
},
|
||||
{
|
||||
key: 'glm-4.5-air',
|
||||
label: 'GLM-4.5-Air',
|
||||
expr: 'p < 32000 && c < 200 ? tier("short_output", p * 0.4 + c * 1 + cr * 0.08) : p < 32000 && c >= 200 ? tier("long_output", p * 0.4 + c * 3 + cr * 0.08) : tier("mid_context", p * 0.6 + c * 4 + cr * 0.12)',
|
||||
},
|
||||
{
|
||||
key: 'gpt-5.4-fast',
|
||||
label: 'GPT-5.4 Fast',
|
||||
expr: 'tier("default", p * 1.25 + c * 5 + cr * 0.125)',
|
||||
requestRules: [
|
||||
{ conditions: [{ source: SOURCE_PARAM, path: 'service_tier', mode: MATCH_EQ, value: 'fast' }], multiplier: '2' },
|
||||
group: '阶梯计费',
|
||||
presets: [
|
||||
{ key: 'claude-sonnet', label: 'Claude Sonnet 4.5', expr: 'p <= 200000 ? tier("standard", p * 1.5 + c * 7.5 + cr * 0.15 + cc * 1.875 + cc1h * 3) : tier("long_context", p * 3 + c * 11.25 + cr * 0.3 + cc * 3.75 + cc1h * 6)' },
|
||||
{ key: 'qwen3-max', label: 'Qwen3-Max', expr: 'p <= 32000 ? tier("short", p * 0.6 + c * 3 + cr * 0.12 + cc * 0.75) : p <= 128000 ? tier("mid", p * 1.2 + c * 6 + cr * 0.24 + cc * 1.5) : tier("long", p * 1.5 + c * 7.5 + cr * 0.3 + cc * 1.875)' },
|
||||
{ key: 'glm-4.5-air', label: 'GLM-4.5-Air', expr: 'p < 32000 && c < 200 ? tier("short_output", p * 0.4 + c * 1 + cr * 0.08) : p < 32000 && c >= 200 ? tier("long_output", p * 0.4 + c * 3 + cr * 0.08) : tier("mid_context", p * 0.6 + c * 4 + cr * 0.12)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'flat',
|
||||
label: 'Flat',
|
||||
expr: 'tier("default", p * 1 + c * 2)',
|
||||
},
|
||||
{
|
||||
key: 'night-discount',
|
||||
label: '夜间半价',
|
||||
expr: 'tier("default", p * 1.5 + c * 7.5)',
|
||||
requestRules: [
|
||||
{ conditions: [{ source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai', mode: MATCH_RANGE, rangeStart: '21', rangeEnd: '6' }], multiplier: '0.5' },
|
||||
group: '多模态',
|
||||
presets: [
|
||||
{ key: 'qwen3-omni-flash', label: 'Qwen3-Omni-Flash', expr: 'tier("base", p * 0.215 + c * 1.53 + img * 0.39 + ai * 1.905 + ao * 7.555)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'weekend-discount',
|
||||
label: '周末8折',
|
||||
expr: 'tier("default", p * 1.5 + c * 7.5)',
|
||||
requestRules: [
|
||||
{ conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '0' }], multiplier: '0.8' },
|
||||
{ conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '6' }], multiplier: '0.8' },
|
||||
group: '请求条件',
|
||||
presets: [
|
||||
{
|
||||
key: 'claude-opus-fast', label: 'Claude Opus 4.6 Fast',
|
||||
expr: 'tier("base", p * 2.5 + c * 12.5 + cr * 0.25 + cc * 3.125 + cc1h * 5)',
|
||||
requestRules: [{ conditions: [{ source: SOURCE_HEADER, path: 'anthropic-beta', mode: MATCH_CONTAINS, value: 'fast-mode-2026-02-01' }], multiplier: '6' }],
|
||||
},
|
||||
{
|
||||
key: 'gpt-5.4-fast', label: 'GPT-5.4 Fast',
|
||||
expr: 'tier("base", p * 1.25 + c * 5 + cr * 0.125)',
|
||||
requestRules: [{ conditions: [{ source: SOURCE_PARAM, path: 'service_tier', mode: MATCH_EQ, value: 'fast' }], multiplier: '2' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'new-year-promo',
|
||||
label: '新年促销',
|
||||
expr: 'tier("default", p * 1.5 + c * 7.5)',
|
||||
requestRules: [
|
||||
{ conditions: [
|
||||
{ source: SOURCE_TIME, timeFunc: 'month', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' },
|
||||
{ source: SOURCE_TIME, timeFunc: 'day', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' },
|
||||
], multiplier: '0.5' },
|
||||
group: '时间促销',
|
||||
presets: [
|
||||
{
|
||||
key: 'night-discount', label: '夜间半价',
|
||||
expr: 'tier("base", p * 1.5 + c * 7.5)',
|
||||
requestRules: [{ conditions: [{ source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai', mode: MATCH_RANGE, rangeStart: '21', rangeEnd: '6' }], multiplier: '0.5' }],
|
||||
},
|
||||
{
|
||||
key: 'weekend-discount', label: '周末8折',
|
||||
expr: 'tier("base", p * 1.5 + c * 7.5)',
|
||||
requestRules: [
|
||||
{ conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '0' }], multiplier: '0.8' },
|
||||
{ conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '6' }], multiplier: '0.8' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'new-year-promo', label: '新年促销',
|
||||
expr: 'tier("base", p * 1.5 + c * 7.5)',
|
||||
requestRules: [{ conditions: [
|
||||
{ source: SOURCE_TIME, timeFunc: 'month', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' },
|
||||
{ source: SOURCE_TIME, timeFunc: 'day', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' },
|
||||
], multiplier: '0.5' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const PRESET_DEFAULT_VISIBLE = 2;
|
||||
|
||||
function PresetSection({ applyPreset, t }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibleGroups = expanded ? PRESET_GROUPS : PRESET_GROUPS.slice(0, PRESET_DEFAULT_VISIBLE);
|
||||
const hasMore = PRESET_GROUPS.length > PRESET_DEFAULT_VISIBLE;
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Text size='small' style={{ color: 'var(--semi-color-text-2)' }}>
|
||||
{t('预设模板')}
|
||||
</Text>
|
||||
{hasMore && (
|
||||
<Button
|
||||
theme='borderless'
|
||||
size='small'
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{ padding: '0 4px', fontSize: 12, color: 'var(--semi-color-primary)' }}
|
||||
>
|
||||
{expanded ? t('收起') : t('更多模板...')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{visibleGroups.map((g) => (
|
||||
<div key={g.group} style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<Tag size='small' color='grey' style={{ minWidth: 60, textAlign: 'center' }}>
|
||||
{t(g.group)}
|
||||
</Tag>
|
||||
{g.presets.map((p) => (
|
||||
<Button key={p.key} size='small' theme='light' onClick={() => applyPreset(p)}>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RawExprEditor({ exprString, onChange, t }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -843,10 +912,13 @@ function RawExprEditor({ exprString, onChange, t }) {
|
||||
// Cache token inputs for estimator — auto-shown when expression uses cache vars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CACHE_ESTIMATOR_FIELDS = [
|
||||
const EXTRA_ESTIMATOR_FIELDS = [
|
||||
{ var: 'cr', stateKey: 'cacheReadTokens', labelKey: '缓存读取 Token (cr)' },
|
||||
{ var: 'cc', stateKey: 'cacheCreateTokens', labelKey: '缓存创建 Token (cc)' },
|
||||
{ var: 'cc1h', stateKey: 'cacheCreate1hTokens', labelKey: '缓存创建-1小时 (cc1h)' },
|
||||
{ var: 'img', stateKey: 'imageTokens', labelKey: '图片输入 Token (img)' },
|
||||
{ var: 'ai', stateKey: 'audioInputTokens', labelKey: '音频输入 Token (ai)' },
|
||||
{ var: 'ao', stateKey: 'audioOutputTokens', labelKey: '音频补全 Token (ao)' },
|
||||
];
|
||||
|
||||
function CacheTokenEstimatorInputs({
|
||||
@@ -854,25 +926,34 @@ function CacheTokenEstimatorInputs({
|
||||
cacheReadTokens, setCacheReadTokens,
|
||||
cacheCreateTokens, setCacheCreateTokens,
|
||||
cacheCreate1hTokens, setCacheCreate1hTokens,
|
||||
imageTokens, setImageTokens,
|
||||
audioInputTokens, setAudioInputTokens,
|
||||
audioOutputTokens, setAudioOutputTokens,
|
||||
t,
|
||||
}) {
|
||||
const setters = {
|
||||
cacheReadTokens: setCacheReadTokens,
|
||||
cacheCreateTokens: setCacheCreateTokens,
|
||||
cacheCreate1hTokens: setCacheCreate1hTokens,
|
||||
imageTokens: setImageTokens,
|
||||
audioInputTokens: setAudioInputTokens,
|
||||
audioOutputTokens: setAudioOutputTokens,
|
||||
};
|
||||
const values = {
|
||||
cacheReadTokens,
|
||||
cacheCreateTokens,
|
||||
cacheCreate1hTokens,
|
||||
imageTokens,
|
||||
audioInputTokens,
|
||||
audioOutputTokens,
|
||||
};
|
||||
|
||||
const usesCache = useMemo(() => {
|
||||
const usesExtra = useMemo(() => {
|
||||
if (!effectiveExpr) return false;
|
||||
return /\b(cr|cc1h|cc)\b/.test(effectiveExpr);
|
||||
return /\b(cr|cc1h|cc|img|ai|ao)\b/.test(effectiveExpr);
|
||||
}, [effectiveExpr]);
|
||||
|
||||
if (!usesCache) return null;
|
||||
if (!usesExtra) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -883,7 +964,7 @@ function CacheTokenEstimatorInputs({
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{CACHE_ESTIMATOR_FIELDS.map((cf) => (
|
||||
{EXTRA_ESTIMATOR_FIELDS.map((cf) => (
|
||||
<div key={cf.var}>
|
||||
<Text size='small' className='mb-1' style={{ display: 'block' }}>
|
||||
{t(cf.labelKey)}
|
||||
@@ -904,7 +985,7 @@ function CacheTokenEstimatorInputs({
|
||||
// Cost estimator (works with any Expr string)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function evalExprLocally(exprStr, p, c, cr, cc, cc1h) {
|
||||
function evalExprLocally(exprStr, p, c, cr, cc, cc1h, img, ai, ao) {
|
||||
try {
|
||||
let matchedTier = '';
|
||||
const tierFn = (name, value) => {
|
||||
@@ -917,6 +998,17 @@ function evalExprLocally(exprStr, p, c, cr, cc, cc1h) {
|
||||
cr: cr || 0,
|
||||
cc: cc || 0,
|
||||
cc1h: cc1h || 0,
|
||||
img: img || 0,
|
||||
ai: ai || 0,
|
||||
ao: ao || 0,
|
||||
prompt_tokens: p,
|
||||
completion_tokens: c,
|
||||
cache_read_tokens: cr || 0,
|
||||
cache_create_tokens: cc || 0,
|
||||
cache_create_1h_tokens: cc1h || 0,
|
||||
image_tokens: img || 0,
|
||||
audio_input_tokens: ai || 0,
|
||||
audio_output_tokens: ao || 0,
|
||||
tier: tierFn,
|
||||
max: Math.max,
|
||||
min: Math.min,
|
||||
@@ -995,37 +1087,47 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
|
||||
const ph = TIME_FUNC_PLACEHOLDERS[normalized.timeFunc] || '';
|
||||
const hint = TIME_FUNC_HINTS[normalized.timeFunc] || '';
|
||||
return (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{
|
||||
marginBottom: 8,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{sourceSelect}
|
||||
<Select
|
||||
size='small'
|
||||
value={normalized.timeFunc}
|
||||
onChange={(value) => onChange({ ...normalized, timeFunc: value })}
|
||||
style={{ width: 80 }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{TIME_FUNCS.map((fn) => (
|
||||
<Select.Option key={fn} value={fn}>{t(TIME_FUNC_LABELS[fn] || fn)}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size='small'
|
||||
value={normalized.timezone}
|
||||
onChange={(value) => onChange({ ...normalized, timezone: value })}
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('时区')}
|
||||
style={{ width: 180 }}
|
||||
>
|
||||
{COMMON_TIMEZONES.map((tz) => (
|
||||
<Select.Option key={tz.value} value={tz.value}>{tz.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
{removeBtn}
|
||||
</div>
|
||||
<Select
|
||||
size='small'
|
||||
value={normalized.timezone}
|
||||
onChange={(value) => onChange({ ...normalized, timezone: value })}
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('时区')}
|
||||
>
|
||||
{COMMON_TIMEZONES.map((tz) => (
|
||||
<Select.Option key={tz.value} value={tz.value}>{tz.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<Select
|
||||
size='small'
|
||||
value={normalized.mode}
|
||||
onChange={(value) => onChange(normalizeCondition({ ...normalized, mode: value }))}
|
||||
style={{ width: 100 }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{matchOptions.map((item) => (
|
||||
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
|
||||
@@ -1038,12 +1140,11 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
|
||||
<Input size='small' value={normalized.rangeEnd} placeholder={ph} style={{ flex: 1 }} onChange={(value) => onChange({ ...normalized, rangeEnd: value })} />
|
||||
</div>
|
||||
) : (
|
||||
<Input size='small' value={normalized.value} placeholder={ph} style={{ flex: 1, minWidth: 60 }} onChange={(value) => onChange({ ...normalized, value })} />
|
||||
<Input size='small' value={normalized.value} placeholder={ph} style={{ flex: 1 }} onChange={(value) => onChange({ ...normalized, value })} />
|
||||
)}
|
||||
{removeBtn}
|
||||
</div>
|
||||
{hint && (
|
||||
<Text size='small' style={{ color: 'var(--semi-color-text-3)', marginLeft: 116, marginTop: 2, display: 'block' }}>
|
||||
<Text size='small' style={{ color: 'var(--semi-color-text-3)' }}>
|
||||
{t(hint)}
|
||||
</Text>
|
||||
)}
|
||||
@@ -1053,20 +1154,27 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
|
||||
|
||||
const showValue = normalized.mode !== MATCH_EXISTS;
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||
<div style={{
|
||||
marginBottom: 8,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr auto',
|
||||
gap: '6px 8px',
|
||||
}}>
|
||||
{sourceSelect}
|
||||
<Input
|
||||
size='small'
|
||||
value={normalized.path}
|
||||
placeholder={normalized.source === SOURCE_HEADER ? t('例如 anthropic-beta') : t('例如 service_tier')}
|
||||
onChange={(value) => onChange({ ...normalized, path: value })}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
{removeBtn}
|
||||
<Select
|
||||
size='small'
|
||||
value={normalized.mode}
|
||||
onChange={(value) => onChange(normalizeCondition({ ...normalized, mode: value, value: value === MATCH_EXISTS ? '' : normalized.value }))}
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
{matchOptions.map((item) => (
|
||||
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
|
||||
@@ -1078,9 +1186,8 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
|
||||
placeholder={normalized.mode === MATCH_CONTAINS ? t('匹配内容') : normalized.mode === MATCH_EXISTS ? '' : t('匹配值')}
|
||||
disabled={!showValue}
|
||||
onChange={(value) => onChange({ ...normalized, value })}
|
||||
style={{ flex: 1, minWidth: 80 }}
|
||||
/>
|
||||
{removeBtn}
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1172,6 +1279,9 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
|
||||
const [cacheReadTokens, setCacheReadTokens] = useState(0);
|
||||
const [cacheCreateTokens, setCacheCreateTokens] = useState(0);
|
||||
const [cacheCreate1hTokens, setCacheCreate1hTokens] = useState(0);
|
||||
const [imageTokens, setImageTokens] = useState(0);
|
||||
const [audioInputTokens, setAudioInputTokens] = useState(0);
|
||||
const [audioOutputTokens, setAudioOutputTokens] = useState(0);
|
||||
|
||||
const currentRequestRuleExpr = requestRuleExpr || '';
|
||||
const parsedRequestRuleGroups = useMemo(
|
||||
@@ -1282,9 +1392,11 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
|
||||
() => evalExprLocally(
|
||||
effectiveExpr, promptTokens, completionTokens,
|
||||
cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens,
|
||||
imageTokens, audioInputTokens, audioOutputTokens,
|
||||
),
|
||||
[effectiveExpr, promptTokens, completionTokens,
|
||||
cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens],
|
||||
cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens,
|
||||
imageTokens, audioInputTokens, audioOutputTokens],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -1301,24 +1413,7 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6 }}>
|
||||
<Text
|
||||
size='small'
|
||||
style={{ color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
{t('预设模板')}:
|
||||
</Text>
|
||||
{PRESETS.map((p) => (
|
||||
<Button
|
||||
key={p.key}
|
||||
size='small'
|
||||
theme='light'
|
||||
onClick={() => applyPreset(p)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<PresetSection applyPreset={applyPreset} t={t} />
|
||||
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
@@ -1440,6 +1535,12 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
|
||||
setCacheCreateTokens={setCacheCreateTokens}
|
||||
cacheCreate1hTokens={cacheCreate1hTokens}
|
||||
setCacheCreate1hTokens={setCacheCreate1hTokens}
|
||||
imageTokens={imageTokens}
|
||||
setImageTokens={setImageTokens}
|
||||
audioInputTokens={audioInputTokens}
|
||||
setAudioInputTokens={setAudioInputTokens}
|
||||
audioOutputTokens={audioOutputTokens}
|
||||
setAudioOutputTokens={setAudioOutputTokens}
|
||||
t={t}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -14,17 +14,17 @@ 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: 'Asia/Shanghai', label: 'UTC+8 北京 (Asia/Shanghai)' },
|
||||
{ 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 悉尼)' },
|
||||
{ value: 'America/New_York', label: 'UTC-5 纽约 (America/New_York)' },
|
||||
{ value: 'America/Los_Angeles', label: 'UTC-8 洛杉矶 (America/Los_Angeles)' },
|
||||
{ value: 'America/Chicago', label: 'UTC-6 芝加哥 (America/Chicago)' },
|
||||
{ value: 'Europe/London', label: 'UTC+0 伦敦 (Europe/London)' },
|
||||
{ value: 'Europe/Berlin', label: 'UTC+1 柏林 (Europe/Berlin)' },
|
||||
{ value: 'Asia/Tokyo', label: 'UTC+9 东京 (Asia/Tokyo)' },
|
||||
{ value: 'Asia/Singapore', label: 'UTC+8 新加坡 (Asia/Singapore)' },
|
||||
{ value: 'Asia/Seoul', label: 'UTC+9 首尔 (Asia/Seoul)' },
|
||||
{ value: 'Australia/Sydney', label: 'UTC+10 悉尼 (Australia/Sydney)' },
|
||||
];
|
||||
|
||||
export const NUMERIC_LITERAL_REGEX =
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import {
|
||||
@@ -859,7 +877,7 @@ export function useModelPricingEditorState({
|
||||
upsertModel(selectedModel.name, (model) => {
|
||||
const next = { ...model, billingMode: value };
|
||||
if (value === 'tiered_expr' && !model.billingExpr) {
|
||||
next.billingExpr = 'tier("default", p * 0 + c * 0)';
|
||||
next.billingExpr = 'tier("base", p * 0 + c * 0)';
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user