Files
new-pai-linghe/web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx

1698 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Banner,
Button,
Card,
Collapsible,
Input,
InputNumber,
Radio,
RadioGroup,
Select,
Tag,
TextArea,
Typography,
} from '@douyinfe/semi-ui';
import { IconCopy, IconDelete, IconPlus } from '@douyinfe/semi-icons';
import { renderQuota } from '../../../../helpers/render';
import { copy, showSuccess } from '../../../../helpers';
import { BILLING_EXTRA_VARS, BILLING_CACHE_VAR_MAP, BILLING_CONDITION_VARS } from '../../../../constants';
import {
createEmptyCondition,
createEmptyTimeCondition,
createEmptyRuleGroup,
createEmptyTimeRuleGroup,
getRequestRuleMatchOptions,
normalizeCondition,
tryParseRequestRuleExpr,
buildRequestRuleExpr,
combineBillingExpr,
splitBillingExprAndRequestRules,
MATCH_EQ,
MATCH_EXISTS,
MATCH_CONTAINS,
MATCH_RANGE,
MATCH_GTE,
SOURCE_HEADER,
SOURCE_PARAM,
SOURCE_TIME,
TIME_FUNCS,
COMMON_TIMEZONES,
} from './requestRuleExpr';
const { Text } = Typography;
const PRICE_SUFFIX = '$/1M tokens';
function unitCostToPrice(uc) {
return Number(uc) || 0;
}
function priceToUnitCost(price) {
return Number(price) || 0;
}
const OPS = ['<', '<=', '>', '>='];
const VAR_OPTIONS = [
{ value: 'len', label: 'len (长度)' },
{ value: 'p', label: 'p (输入)' },
{ value: 'c', label: 'c (输出)' },
];
const CACHE_MODE_TIMED = 'timed';
const CACHE_MODE_GENERIC = 'generic';
function formatTokenHint(n) {
if (n == null || n === '' || Number.isNaN(Number(n))) return '';
const v = Number(n);
if (v === 0) return '= 0';
if (v >= 1000000) return `= ${(v / 1000000).toLocaleString()}M tokens`;
if (v >= 1000) return `= ${(v / 1000).toLocaleString()}K tokens`;
return `= ${v.toLocaleString()} tokens`;
}
// ---------------------------------------------------------------------------
// Expr generation from visual config (multi-condition)
// ---------------------------------------------------------------------------
function buildConditionStr(conditions) {
if (!conditions || conditions.length === 0) return '';
return conditions
.filter((c) => c.var && c.op && c.value != null && c.value !== '')
.map((c) => `${c.var} ${c.op} ${c.value}`)
.join(' && ');
}
const CACHE_VAR_MAP = BILLING_CACHE_VAR_MAP;
function getTierCacheMode(tier) {
if (tier?.cache_mode === CACHE_MODE_TIMED) {
return CACHE_MODE_TIMED;
}
if (tier?.cache_mode === CACHE_MODE_GENERIC) {
return CACHE_MODE_GENERIC;
}
return Number(tier?.cache_create_1h_unit_cost) > 0
? CACHE_MODE_TIMED
: CACHE_MODE_GENERIC;
}
function normalizeVisualTier(tier = {}) {
return {
...tier,
conditions: Array.isArray(tier.conditions) ? tier.conditions : [],
cache_mode: getTierCacheMode(tier),
};
}
function createDefaultVisualConfig() {
return {
tiers: [
normalizeVisualTier({
conditions: [],
input_unit_cost: 0,
output_unit_cost: 0,
label: 'base',
cache_mode: CACHE_MODE_GENERIC,
}),
],
};
}
function normalizeVisualConfig(config) {
if (!config || !Array.isArray(config.tiers) || config.tiers.length === 0) {
return createDefaultVisualConfig();
}
return {
...config,
tiers: config.tiers.map((tier) => normalizeVisualTier(tier)),
};
}
function buildTierBodyExpr(tier) {
const parts = [];
const ic = Number(tier.input_unit_cost) || 0;
const oc = Number(tier.output_unit_cost) || 0;
parts.push(`p * ${ic}`);
parts.push(`c * ${oc}`);
for (const cv of CACHE_VAR_MAP) {
const v = Number(tier[cv.field]) || 0;
if (v !== 0) parts.push(`${cv.exprVar} * ${v}`);
}
return parts.join(' + ');
}
function generateExprFromVisualConfig(config) {
if (!config || !config.tiers || config.tiers.length === 0)
return 'p * 0 + c * 0';
const tiers = config.tiers;
if (tiers.length === 1) {
const t = tiers[0];
const label = t.label || 'default';
const body = `tier("${label}", ${buildTierBodyExpr(t)})`;
const cond = buildConditionStr(t.conditions);
if (cond) {
return `${cond} ? ${body} : p * 0 + c * 0`;
}
return body;
}
const parts = [];
for (let i = 0; i < tiers.length; i++) {
const t = tiers[i];
const label = t.label || `${i + 1}`;
const body = `tier("${label}", ${buildTierBodyExpr(t)})`;
const cond = buildConditionStr(t.conditions);
if (i < tiers.length - 1 && cond) {
parts.push(`${cond} ? ${body}`);
} else {
parts.push(body);
}
}
return parts.join(' : ');
}
// ---------------------------------------------------------------------------
// Reverse-parse an Expr string back into visual config
// ---------------------------------------------------------------------------
function tryParseVisualConfig(exprStr) {
if (!exprStr) return null;
try {
const versionMatch = exprStr.match(/^v\d+:([\s\S]*)$/);
if (versionMatch) exprStr = versionMatch[1];
const cacheVarNames = CACHE_VAR_MAP.map((cv) => cv.exprVar);
const optCacheStr = cacheVarNames
.map((v) => `(?:\\s*\\+\\s*${v}\\s*\\*\\s*([\\d.eE+-]+))?`)
.join('');
// Body pattern: p * X + c * Y [+ cr * A] [+ cc * B] [+ cc1h * C]
const bodyPat = `p\\s*\\*\\s*([\\d.eE+-]+)\\s*\\+\\s*c\\s*\\*\\s*([\\d.eE+-]+)${optCacheStr}`;
// Single-tier: tier("label", body)
const singleRe = new RegExp(`^tier\\("([^"]*)",\\s*${bodyPat}\\)$`);
const simple = exprStr.match(singleRe);
if (simple) {
const tier = {
conditions: [],
input_unit_cost: Number(simple[2]),
output_unit_cost: Number(simple[3]),
label: simple[1],
};
CACHE_VAR_MAP.forEach((cv, i) => {
const val = simple[4 + i];
if (val != null) tier[cv.field] = Number(val);
});
return normalizeVisualConfig({ tiers: [normalizeVisualTier(tier)] });
}
// Multi-tier: cond1 ? tier(body) : cond2 ? tier(body) : tier(body)
const condGroup = `((?:(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)(?:\\s*&&\\s*(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`;
const tierRe = new RegExp(
`(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*${bodyPat}\\)`,
'g',
);
const tiers = [];
let match;
while ((match = tierRe.exec(exprStr)) !== null) {
const condStr = match[1] || '';
const conditions = [];
if (condStr) {
const condParts = condStr.split(/\s*&&\s*/);
for (const cp of condParts) {
const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/);
if (cm) {
conditions.push({ var: cm[1], op: cm[2], value: Number(cm[3]) });
}
}
}
const tier = {
conditions,
input_unit_cost: Number(match[3]),
output_unit_cost: Number(match[4]),
label: match[2],
};
CACHE_VAR_MAP.forEach((cv, i) => {
const val = match[5 + i];
if (val != null) tier[cv.field] = Number(val);
});
tiers.push(normalizeVisualTier(tier));
}
if (tiers.length === 0) return null;
const cfg = normalizeVisualConfig({ tiers });
const regenerated = generateExprFromVisualConfig(cfg);
if (regenerated.replace(/\s+/g, '') !== exprStr.replace(/\s+/g, ''))
return null;
return cfg;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Condition editor row
// ---------------------------------------------------------------------------
function ConditionRow({ cond, onChange, onRemove, t }) {
const hint = formatTokenHint(cond.value);
return (
<div style={{
marginBottom: 6,
display: 'grid',
gridTemplateColumns: '1fr auto 1fr auto',
gap: '4px 6px',
alignItems: 'center',
}}>
<Select
size='small'
value={cond.var || 'len'}
onChange={(val) => onChange({ ...cond, var: val })}
>
{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)',
gridColumn: '3 / 4',
}}
>
= {hint}
</Text>
) : null}
</div>
);
}
// ---------------------------------------------------------------------------
// Price input that preserves intermediate text like "7." or "0.5"
// ---------------------------------------------------------------------------
function PriceInput({ unitCost, field, index, onUpdate, placeholder }) {
const priceFromModel = unitCostToPrice(unitCost);
const [text, setText] = useState(priceFromModel === 0 ? '' : String(priceFromModel));
useEffect(() => {
const current = Number(text);
if (text === '' && priceFromModel === 0) return;
if (!Number.isNaN(current) && current === priceFromModel) return;
setText(priceFromModel === 0 ? '' : String(priceFromModel));
}, [priceFromModel]);
const handleChange = (val) => {
setText(val);
if (val === '') {
onUpdate(index, field, 0);
return;
}
const num = Number(val);
if (!Number.isNaN(num)) {
onUpdate(index, field, priceToUnitCost(num));
}
};
return (
<Input
value={text}
placeholder={placeholder || '0'}
suffix={PRICE_SUFFIX}
onChange={handleChange}
style={{ width: '100%', marginTop: 2 }}
/>
);
}
// ---------------------------------------------------------------------------
// Extended price block (cache fields) — collapsible per tier, with mode switch
// ---------------------------------------------------------------------------
const CACHE_FIELDS_TIMED = [
{ field: 'cache_read_unit_cost', labelKey: '缓存读取价格' },
{ field: 'cache_create_unit_cost', labelKey: '缓存创建价格5分钟' },
{ field: 'cache_create_1h_unit_cost', labelKey: '缓存创建价格1小时' },
];
const CACHE_FIELDS_GENERIC = [
{ field: 'cache_read_unit_cost', labelKey: '缓存读取价格' },
{ field: 'cache_create_unit_cost', labelKey: '缓存创建价格' },
];
function ExtendedPriceBlock({ tier, index, onUpdate, t }) {
const mediaFields = BILLING_EXTRA_VARS.filter((v) => v.group === 'media');
const hasAny = [...CACHE_FIELDS_TIMED, ...mediaFields.map((v) => v.tierField)].some(
(f) => Number(tier[typeof f === 'string' ? f : f.field]) > 0,
);
const [expanded, setExpanded] = useState(hasAny);
const cacheMode = getTierCacheMode(tier);
const handleCacheModeChange = (e) => {
const mode = e.target.value;
const patch = { cache_mode: mode };
if (mode === CACHE_MODE_GENERIC) {
patch.cache_create_1h_unit_cost = 0;
}
onUpdate(index, patch);
};
const activeFields =
cacheMode === CACHE_MODE_TIMED ? CACHE_FIELDS_TIMED : CACHE_FIELDS_GENERIC;
return (
<div style={{ marginTop: 8 }}>
<Button
theme='borderless'
size='small'
onClick={() => setExpanded(!expanded)}
style={{ padding: '2px 0', color: 'var(--semi-color-text-2)', fontSize: 12 }}
>
{expanded ? '▾' : '▸'} {t('扩展价格')}
</Button>
<Collapsible isOpen={expanded}>
<div
style={{
marginTop: 4,
padding: '8px 0',
}}
>
<div className='text-xs text-gray-500 mb-2'>
{t('这些价格都是可选项,不填也可以。')}
</div>
<div style={{ marginBottom: 8 }}>
<RadioGroup
type='button'
size='small'
value={cacheMode}
onChange={handleCacheModeChange}
>
<Radio value={CACHE_MODE_GENERIC}>{t('通用缓存')}</Radio>
<Radio value={CACHE_MODE_TIMED}>{t('分时缓存 (Claude)')}</Radio>
</RadioGroup>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
}}
>
{activeFields.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 className='text-xs text-gray-500 mb-2 mt-3'>
{t('图片/音频价格(可选)')}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
}}
>
{mediaFields.map((v) => ({ field: v.tierField, labelKey: v.label })).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>
);
}
// ---------------------------------------------------------------------------
// Visual Tier Card (multi-condition)
// ---------------------------------------------------------------------------
function VisualTierCard({ tier, index, isLast, isOnly, onUpdate, onRemove, t }) {
const conditions = tier.conditions || [];
const varLabel = { len: t('长度'), p: t('输入'), c: t('输出') };
const condSummary = useMemo(() => {
if (conditions.length === 0) return t('无条件(兜底档)');
return conditions
.filter((c) => c.var && c.op && c.value != null)
.map((c) => `${varLabel[c.var] || c.var} ${c.op} ${formatTokenHint(c.value)}`)
.join(' && ');
}, [conditions, t]);
const updateCondition = (ci, newCond) => {
const next = conditions.map((c, i) => (i === ci ? newCond : c));
onUpdate(index, 'conditions', next);
};
const removeCondition = (ci) => {
onUpdate(
index,
'conditions',
conditions.filter((_, i) => i !== ci),
);
};
const addCondition = () => {
if (conditions.length >= 2) return;
const usedVars = conditions.map((c) => c.var);
const nextVar = usedVars.includes('len') ? 'c' : 'len';
onUpdate(index, 'conditions', [
...conditions,
{ var: nextVar, op: '<', value: 200000 },
]);
};
return (
<div
style={{
padding: '12px 16px',
borderRadius: 8,
border: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-bg-2)',
marginBottom: 8,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag color='blue' size='small'>
{t('第 {{n}} 档', { n: index + 1 })}
</Tag>
{isLast && !isOnly ? (
<Tag color='grey' size='small'>
{t('兜底档')}
</Tag>
) : null}
</div>
{!isOnly ? (
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
onClick={() => onRemove(index)}
/>
) : null}
</div>
{/* Tier label */}
<div style={{ marginBottom: 8 }}>
<Text size='small' style={{ color: 'var(--semi-color-text-2)' }}>
{t('档位名称')}
</Text>
<Input
size='small'
value={tier.label || ''}
placeholder={t('第 {{n}} 档', { n: index + 1 })}
onChange={(val) => onUpdate(index, 'label', val)}
style={{ width: '100%', marginTop: 2 }}
/>
</div>
{/* Conditions */}
{!isLast || isOnly ? (
<div style={{ marginBottom: 10 }}>
<Text
size='small'
style={{
color: 'var(--semi-color-text-2)',
display: 'block',
marginBottom: 4,
}}
>
{t('条件')}
</Text>
{conditions.map((cond, ci) => (
<ConditionRow
key={ci}
cond={cond}
onChange={(nc) => updateCondition(ci, nc)}
onRemove={() => removeCondition(ci)}
t={t}
/>
))}
{conditions.length < 2 && (
<Button
icon={<IconPlus />}
size='small'
theme='borderless'
onClick={addCondition}
style={{ marginTop: 2 }}
>
{t('添加条件')}
</Button>
)}
</div>
) : (
<div
style={{
marginBottom: 10,
padding: '4px 8px',
borderRadius: 4,
background: 'var(--semi-color-fill-1)',
}}
>
<Text size='small' style={{ color: 'var(--semi-color-text-3)' }}>
{condSummary}
</Text>
</div>
)}
{/* Prices */}
<div
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}
>
<div>
<Text size='small' style={{ color: 'var(--semi-color-text-2)' }}>
{t('输入价格')}
</Text>
<PriceInput
unitCost={tier.input_unit_cost}
field='input_unit_cost'
index={index}
onUpdate={onUpdate}
/>
</div>
<div>
<Text size='small' style={{ color: 'var(--semi-color-text-2)' }}>
{t('输出价格')}
</Text>
<PriceInput
unitCost={tier.output_unit_cost}
field='output_unit_cost'
index={index}
onUpdate={onUpdate}
/>
</div>
</div>
{/* Extended prices (cache) — collapsible */}
<ExtendedPriceBlock tier={tier} index={index} onUpdate={onUpdate} t={t} />
</div>
);
}
// ---------------------------------------------------------------------------
// Visual editor
// ---------------------------------------------------------------------------
function VisualEditor({ visualConfig, onChange, t }) {
const config = normalizeVisualConfig(visualConfig);
const tiers = config.tiers || [];
const updateTier = (index, field, value) => {
const patch =
typeof field === 'string' ? { [field]: value } : { ...field };
const next = tiers.map((tier, i) =>
i === index ? normalizeVisualTier({ ...tier, ...patch }) : tier,
);
onChange({ ...config, tiers: next });
};
const addTier = () => {
const newTiers = [...tiers];
if (
newTiers.length > 0 &&
(!newTiers[newTiers.length - 1].conditions ||
newTiers[newTiers.length - 1].conditions.length === 0)
) {
newTiers[newTiers.length - 1] = {
...newTiers[newTiers.length - 1],
conditions: [{ var: 'len', op: '<', value: 200000 }],
};
}
newTiers.push({
conditions: [],
input_unit_cost: 0,
output_unit_cost: 0,
label: `${newTiers.length + 1}`,
cache_mode: CACHE_MODE_GENERIC,
});
onChange({ ...config, tiers: newTiers });
};
const removeTier = (index) => {
if (tiers.length <= 1) return;
const next = tiers.filter((_, i) => i !== index);
if (next.length > 0) {
next[next.length - 1] = {
...next[next.length - 1],
conditions: [],
};
}
onChange({ ...config, tiers: next });
};
return (
<div>
<Banner
type='info'
description={t('每个档位可设置 0~2 个条件(对 len、p 和 c最后一档为兜底档无需条件。len 为输入上下文总长度(含缓存),推荐用于阶梯条件。')}
style={{ marginBottom: 12 }}
/>
{tiers.map((tier, index) => (
<VisualTierCard
key={index}
tier={tier}
index={index}
isLast={index === tiers.length - 1}
isOnly={tiers.length === 1}
onUpdate={updateTier}
onRemove={removeTier}
t={t}
/>
))}
<Button
icon={<IconPlus />}
size='small'
theme='light'
onClick={addTier}
style={{ marginTop: 4 }}
>
{t('添加更多档位')}
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Raw Expr editor with preset templates
// ---------------------------------------------------------------------------
const PRESET_GROUPS = [
{
group: '固定价格',
presets: [
{ key: 'flat', label: 'Flat', expr: 'tier("base", p * 2 + c * 4)' },
{ key: 'claude-opus', label: 'Claude Opus 4.6', expr: 'tier("base", p * 5 + c * 25 + cr * 0.5 + cc * 6.25 + cc1h * 10)' },
{ key: 'gpt-5.4', label: 'GPT-5.4', expr: 'len <= 272000 ? tier("standard", p * 2.5 + c * 15 + cr * 0.25) : tier("long_context", p * 5 + c * 22.5 + cr * 0.5)' },
],
},
{
group: '阶梯计费',
presets: [
{ key: 'claude-sonnet', label: 'Claude Sonnet 4.5', expr: 'len <= 200000 ? tier("standard", p * 3 + c * 15 + cr * 0.3 + cc * 3.75 + cc1h * 6) : tier("long_context", p * 6 + c * 22.5 + cr * 0.6 + cc * 7.5 + cc1h * 12)' },
{ key: 'qwen3-max', label: 'Qwen3 Max', expr: 'len <= 32000 ? tier("short", p * 1.2 + c * 6 + cr * 0.24 + cc * 1.5) : len <= 128000 ? tier("mid", p * 2.4 + c * 12 + cr * 0.48 + cc * 3) : tier("long", p * 3 + c * 15 + cr * 0.6 + cc * 3.75)' },
{ key: 'glm-4.5-air', label: 'GLM-4.5 Air', expr: 'len < 32000 && c < 200 ? tier("short_output", p * 0.8 + c * 2 + cr * 0.16) : len < 32000 && c >= 200 ? tier("long_output", p * 0.8 + c * 6 + cr * 0.16) : tier("mid_context", p * 1.2 + c * 8 + cr * 0.24)' },
{ key: 'doubao-seed-1.8', label: 'Doubao Seed 1.8', expr: 'len <= 32000 && c <= 200 ? tier("discount", p * 0.8 + c * 2 + cr * 0.16 + cc * 0.17) : len <= 32000 ? tier("short", p * 0.8 + c * 8 + cr * 0.16 + cc * 0.17) : len <= 128000 ? tier("mid", p * 1.2 + c * 16 + cr * 0.16 + cc * 0.17) : tier("long", p * 2.4 + c * 24 + cr * 0.16 + cc * 0.17)' },
],
},
{
group: '多模态',
presets: [
{ key: 'gpt-image-1-mini', label: 'GPT Image 1 Mini', expr: 'tier("base", p * 2 + c * 8 + img * 2.5)' },
{ key: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', expr: 'tier("base", p * 0.3 + c * 2.5 + cr * 0.03 + ai * 1.0)' },
{ key: 'gemini-3-pro-image', label: 'Gemini 3 Pro Image', expr: 'tier("base", p * 2 + c * 12 + img_o * 120)' },
{ key: 'qwen3-omni-flash', label: 'Qwen3 Omni Flash', expr: 'tier("base", p * 0.43 + c * 3.06 + img * 0.78 + ai * 3.81 + ao * 15.11)' },
],
},
{
group: '请求条件',
presets: [
{
key: 'claude-opus-fast', label: 'Claude Opus 4.6 Fast',
expr: 'tier("base", p * 5 + c * 25 + cr * 0.5 + cc * 6.25 + cc1h * 10)',
requestRules: [{ conditions: [{ source: SOURCE_HEADER, path: 'anthropic-beta', mode: MATCH_CONTAINS, value: 'fast-mode-2026-02-01' }], multiplier: '6' }],
},
{
key: 'gpt-5.4-tiers', label: 'GPT-5.4 Priority/Flex',
expr: 'len <= 272000 ? tier("standard", p * 2.5 + c * 15 + cr * 0.25) : tier("long_context", p * 5 + c * 22.5 + cr * 0.5)',
requestRules: [
{ conditions: [{ source: SOURCE_PARAM, path: 'service_tier', mode: MATCH_EQ, value: 'priority' }], multiplier: '2' },
{ conditions: [{ source: SOURCE_PARAM, path: 'service_tier', mode: MATCH_EQ, value: 'flex' }], multiplier: '0.5' },
],
},
],
},
{
group: '时间促销',
presets: [
{
key: 'night-discount', label: '夜间半价',
expr: 'tier("base", p * 3 + c * 15)',
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 * 3 + c * 15)',
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 * 3 + c * 15)',
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>
<Banner
type='info'
description={
<div>
<div>
{t('变量')}: <code>p</code> ({t(' Token')}), <code>c</code> (
{t('输出 Token')}), <code>len</code> ({t('')}),{' '}
<code>cr</code> ({t('')}),{' '}
<code>cc</code> ({t('')}),{' '}
<code>cc1h</code> ({t('-1')})
</div>
<div>
{t('函数')}: <code>tier(name, value)</code>,{' '}
<code>max(a, b)</code>, <code>min(a, b)</code>,{' '}
<code>ceil(x)</code>, <code>floor(x)</code>,{' '}
<code>abs(x)</code>, <code>header(name)</code>,{' '}
<code>param(path)</code>, <code>has(source, text)</code>
</div>
</div>
}
style={{ marginBottom: 12 }}
/>
<TextArea
value={exprString}
onChange={onChange}
autosize={{ minRows: 3, maxRows: 12 }}
style={{ fontFamily: 'monospace', fontSize: 13 }}
placeholder={t('输入计费表达式...')}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Cache token inputs for estimator — auto-shown when expression uses cache vars
// ---------------------------------------------------------------------------
const EXTRA_ESTIMATOR_FIELDS = BILLING_EXTRA_VARS.map((v) => ({
var: v.key,
stateKey: v.field.replace('Price', 'Tokens'),
labelKey: `${v.shortLabel} Token (${v.key})`,
}));
function CacheTokenEstimatorInputs({
effectiveExpr,
extraTokenValues,
extraTokenSetters,
t,
}) {
const usesExtra = useMemo(() => {
if (!effectiveExpr) return false;
const varNames = EXTRA_ESTIMATOR_FIELDS.map((f) => f.var.replace('_', '_')).join('|');
return new RegExp(`\\b(${varNames})\\b`).test(effectiveExpr);
}, [effectiveExpr]);
if (!usesExtra) return null;
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 12,
marginBottom: 12,
}}
>
{EXTRA_ESTIMATOR_FIELDS.map((cf) => (
<div key={cf.var}>
<Text size='small' className='mb-1' style={{ display: 'block' }}>
{t(cf.labelKey)}
</Text>
<InputNumber
value={extraTokenValues[cf.stateKey]}
min={0}
onChange={(val) => extraTokenSetters[cf.stateKey](val ?? 0)}
style={{ width: '100%' }}
/>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Cost estimator (works with any Expr string)
// ---------------------------------------------------------------------------
function evalExprLocally(exprStr, p, c, extraTokenValues) {
try {
let matchedTier = '';
const tierFn = (name, value) => {
matchedTier = name;
return value;
};
const cacheReadTokens = extraTokenValues.cacheReadTokens || 0;
const cacheCreateTokens = extraTokenValues.cacheCreateTokens || 0;
const cacheCreate1hTokens = extraTokenValues.cacheCreate1hTokens || 0;
const len = p + cacheReadTokens + cacheCreateTokens + cacheCreate1hTokens;
const env = { p, c, len, tier: tierFn, max: Math.max, min: Math.min, abs: Math.abs, ceil: Math.ceil, floor: Math.floor };
for (const field of EXTRA_ESTIMATOR_FIELDS) {
env[field.var] = extraTokenValues[field.stateKey] || 0;
}
const fn = new Function(
...Object.keys(env),
`"use strict"; return (${exprStr});`,
);
return { cost: fn(...Object.values(env)), matchedTier, error: null };
} catch (e) {
return { cost: 0, matchedTier: '', error: e.message };
}
}
// ---------------------------------------------------------------------------
// Request condition rule row (moved from RequestMultiplierEditor)
// ---------------------------------------------------------------------------
const TIME_FUNC_LABELS = {
hour: '小时',
minute: '分钟',
weekday: '星期',
month: '月份',
day: '日期',
};
const TIME_FUNC_HINTS = {
hour: '0~23',
minute: '0~59',
weekday: '0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六',
month: '1=一月 ... 12=十二月',
day: '1~31',
};
const TIME_FUNC_PLACEHOLDERS = {
hour: '0-23',
minute: '0-59',
weekday: '0-6',
month: '1-12',
day: '1-31',
};
function RuleConditionRow({ cond, onChange, onRemove, t }) {
const normalized = normalizeCondition(cond);
const isTime = normalized.source === SOURCE_TIME;
const matchOptions = getRequestRuleMatchOptions(normalized.source, t);
const sourceSelect = (
<Select
size='small'
value={normalized.source}
onChange={(value) => {
if (value === SOURCE_TIME) {
onChange(normalizeCondition({ source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai', mode: MATCH_GTE }));
} else {
onChange(normalizeCondition({ source: value, path: '', mode: MATCH_EQ }));
}
}}
style={{ width: 110 }}
>
<Select.Option value={SOURCE_PARAM}>{t('请求参数')}</Select.Option>
<Select.Option value={SOURCE_HEADER}>{t('请求头')}</Select.Option>
<Select.Option value={SOURCE_TIME}>{t('时间条件')}</Select.Option>
</Select>
);
const removeBtn = (
<Button icon={<IconDelete />} type='danger' theme='borderless' size='small' onClick={onRemove} />
);
if (isTime) {
const isRange = normalized.mode === MATCH_RANGE;
const ph = TIME_FUNC_PLACEHOLDERS[normalized.timeFunc] || '';
const hint = TIME_FUNC_HINTS[normalized.timeFunc] || '';
return (
<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={{ flex: 1 }}
>
{TIME_FUNCS.map((fn) => (
<Select.Option key={fn} value={fn}>{t(TIME_FUNC_LABELS[fn] || fn)}</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={{ flex: 1 }}
>
{matchOptions.map((item) => (
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
))}
</Select>
{isRange ? (
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flex: 1 }}>
<Input size='small' value={normalized.rangeStart} placeholder={ph} style={{ flex: 1 }} onChange={(value) => onChange({ ...normalized, rangeStart: value })} />
<span>~</span>
<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 }} onChange={(value) => onChange({ ...normalized, value })} />
)}
</div>
{hint && (
<Text size='small' style={{ color: 'var(--semi-color-text-3)' }}>
{t(hint)}
</Text>
)}
</div>
);
}
const showValue = normalized.mode !== MATCH_EXISTS;
return (
<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 })}
/>
{removeBtn}
<Select
size='small'
value={normalized.mode}
onChange={(value) => onChange(normalizeCondition({ ...normalized, mode: value, value: value === MATCH_EXISTS ? '' : normalized.value }))}
>
{matchOptions.map((item) => (
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
))}
</Select>
<Input
size='small'
value={normalized.value}
placeholder={normalized.mode === MATCH_CONTAINS ? t('匹配内容') : normalized.mode === MATCH_EXISTS ? '' : t('匹配值')}
disabled={!showValue}
onChange={(value) => onChange({ ...normalized, value })}
/>
<div />
</div>
);
}
function RuleGroupCard({ group, index, onChange, onRemove, t }) {
const conditions = group.conditions || [];
const updateCondition = (ci, newCond) => {
const next = conditions.map((c, i) => (i === ci ? newCond : c));
onChange({ ...group, conditions: next });
};
const removeCondition = (ci) => {
const next = conditions.filter((_, i) => i !== ci);
onChange({ ...group, conditions: next.length > 0 ? next : [createEmptyCondition()] });
};
const addCondition = (cond) => {
onChange({ ...group, conditions: [...conditions, cond] });
};
return (
<div
style={{
padding: '12px 16px',
borderRadius: 8,
border: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-bg-2)',
marginBottom: 8,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Tag color='blue' size='small'>
{t('第 {{n}} 组', { n: index + 1 })}
</Tag>
<Button icon={<IconDelete />} type='danger' theme='borderless' size='small' onClick={onRemove} />
</div>
<div style={{ marginBottom: 8 }}>
<Text size='small' style={{ color: 'var(--semi-color-text-2)', display: 'block', marginBottom: 4 }}>
{t('条件')}{conditions.length > 1 ? ` (${t('同时满足')})` : ''}
</Text>
{conditions.map((cond, ci) => (
<RuleConditionRow
key={ci}
cond={cond}
onChange={(nc) => updateCondition(ci, nc)}
onRemove={() => removeCondition(ci)}
t={t}
/>
))}
<div style={{ display: 'flex', gap: 6 }}>
<Button icon={<IconPlus />} size='small' theme='borderless' onClick={() => addCondition(createEmptyCondition())}>
{t('添加条件')}
</Button>
<Button icon={<IconPlus />} size='small' theme='borderless' onClick={() => addCondition(createEmptyTimeCondition())}>
{t('添加时间条件')}
</Button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text size='small' style={{ color: 'var(--semi-color-text-2)', whiteSpace: 'nowrap' }}>
{t('倍率')}
</Text>
<Input
size='small'
value={group.multiplier || ''}
placeholder={t('例如 0.5 或 2')}
suffix='x'
onChange={(value) => onChange({ ...group, multiplier: value })}
style={{ width: 160 }}
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// LLM prompt helper — copyable prompt for LLM-assisted expression design
// ---------------------------------------------------------------------------
const LLM_PROMPT_TEMPLATE = `你是一个 AI API 计费表达式设计助手。用户需要你帮忙设计一个计费表达式billing expression用于 AI API 网关的模型计费。
## 表达式语言
表达式基于 expr-lang/expr支持标准算术运算和三元运算符。
### Token 变量
输入侧:
- p — 输入 token 数(计价用)。系统会自动排除表达式中单独计价的子类别(如用了 cr缓存 token 就从 p 中扣除)
- len — 输入上下文总长度(条件判断用)。不受自动排除影响,始终反映完整输入长度。用于阶梯条件判断
- cr — 缓存命中读取token 数
- cc — 缓存创建 token 数5分钟 TTL
- cc1h — 缓存创建 token 数1小时 TTLClaude 专用)
- img — 图片输入 token 数
- ai — 音频输入 token 数
输出侧:
- c — 输出 token 数。同样会自动排除单独计价的子类别
- img_o — 图片输出 token 数
- ao — 音频输出 token 数
### p/c 自动排除机制
p 和 c 是兜底变量,代表所有没有被表达式单独定价的 token。如果表达式使用了某个子类别变量如 cr对应 token 就从 p 中扣除,避免重复计费。没用到的子类别 token 则留在 p/c 中按基础价格计费。
重要len 不受自动排除影响。阶梯条件应使用 len 而非 p以避免缓存命中导致 p 降低而误判档位。
### 内置函数
- tier(name, value) — 标记计费档位名称,必须包裹费用表达式
- max(a, b)、min(a, b) — 取大/小值
- ceil(x)、floor(x)、abs(x) — 向上取整、向下取整、绝对值
- header(name) — 读取请求头
- param(path) — 读取请求体 JSON 路径gjson 语法)
- has(source, substr) — 子字符串检查
- hour(tz)、minute(tz)、weekday(tz)、month(tz)、day(tz) — 时间函数tz 为时区如 "Asia/Shanghai"
### 价格系数
表达式中的数字系数是 $/1M tokens 的价格。例如 p * 2.5 表示输入 $2.50/1M tokens。
## 表达式示例
简单定价:
tier("base", p * 2.5 + c * 15)
带缓存的定价:
tier("base", p * 2.5 + c * 15 + cr * 0.25)
多档阶梯(用 len 做条件):
len <= 200000
? tier("standard", p * 3 + c * 15 + cr * 0.3 + cc * 3.75 + cc1h * 6)
: tier("long_context", p * 6 + c * 22.5 + cr * 0.6 + cc * 7.5 + cc1h * 12)
图片模型:
tier("base", p * 2 + c * 8 + img * 2.5)
多模态含音频:
tier("base", p * 0.43 + c * 3.06 + img * 0.78 + ai * 3.81 + ao * 15.11)
三档阶梯示例:
len <= 128000
? tier("standard", p * 1.1 + c * 4.4)
: (len <= 1000000
? tier("medium", p * 2.2 + c * 8.8)
: tier("long", p * 4.4 + c * 17.6))
## 规则
1. 每个叶子分支必须用 tier("名称", 费用表达式) 包裹
2. tier 名称用英文,如 "base"、"standard"、"long_context"
3. 阶梯条件用 len不要用 p支持 <、<=、>、>=
4. 多档用嵌套三元运算符条件1 ? tier(...) : (条件2 ? tier(...) : tier(...))
5. 价格系数直接写供应商官方 $/1M tokens 价格
6. 不需要缓存/图片/音频单独定价时可以不写对应变量,它们的 token 会自动包含在 p/c 中
请根据用户提供的模型信息和定价需求,生成计费表达式。`;
function LlmPromptHelper({ t, model }) {
const [open, setOpen] = useState(false);
const modelName = model?.name || '';
const prompt = useMemo(() => {
if (modelName) {
return LLM_PROMPT_TEMPLATE + `\n\n当前模型:${modelName}`;
}
return LLM_PROMPT_TEMPLATE;
}, [modelName]);
const handleCopy = useCallback(async () => {
const ok = await copy(prompt);
if (ok) showSuccess(t('已复制到剪贴板'));
}, [prompt, t]);
return (
<div style={{ marginBottom: 12 }}>
<Button
theme='borderless'
size='small'
icon={<IconCopy />}
onClick={() => setOpen(!open)}
style={{ color: 'var(--semi-color-tertiary)' }}
>
{t('LLM 辅助设计提示词')}
</Button>
<Collapsible isOpen={open}>
<Card
bodyStyle={{ padding: 12 }}
style={{ marginTop: 8, background: 'var(--semi-color-fill-0)' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text size='small' type='secondary'>
{t('复制以下提示词发送给 LLM如 ChatGPT / Claude让它帮你设计计费表达式')}
</Text>
<Button
icon={<IconCopy />}
size='small'
theme='light'
onClick={handleCopy}
>
{t('复制提示词')}
</Button>
</div>
<TextArea
value={prompt}
readonly
autosize={{ minRows: 6, maxRows: 20 }}
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Card>
</Collapsible>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function TieredPricingEditor({ model, onExprChange, requestRuleExpr, onRequestRuleExprChange, t }) {
const currentExpr = model?.billingExpr || '';
const [editorMode, setEditorMode] = useState('visual');
const [visualConfig, setVisualConfig] = useState(null);
const [rawExpr, setRawExpr] = useState('');
const [promptTokens, setPromptTokens] = useState(200000);
const [completionTokens, setCompletionTokens] = useState(10000);
const [cacheReadTokens, setCacheReadTokens] = useState(0);
const [cacheCreateTokens, setCacheCreateTokens] = useState(0);
const [cacheCreate1hTokens, setCacheCreate1hTokens] = useState(0);
const [imageTokens, setImageTokens] = useState(0);
const [imageOutputTokens, setImageOutputTokens] = useState(0);
const [audioInputTokens, setAudioInputTokens] = useState(0);
const [audioOutputTokens, setAudioOutputTokens] = useState(0);
const currentRequestRuleExpr = requestRuleExpr || '';
const parsedRequestRuleGroups = useMemo(
() => tryParseRequestRuleExpr(currentRequestRuleExpr),
[currentRequestRuleExpr],
);
const canUseVisualRules = parsedRequestRuleGroups !== null;
const [requestRuleGroups, setRequestRuleGroups] = useState(parsedRequestRuleGroups || []);
useEffect(() => {
if (parsedRequestRuleGroups) {
setRequestRuleGroups(parsedRequestRuleGroups);
} else {
setRequestRuleGroups([]);
}
}, [currentRequestRuleExpr, parsedRequestRuleGroups]);
const handleRequestRuleGroupsChange = useCallback((nextGroups) => {
setRequestRuleGroups(nextGroups);
onRequestRuleExprChange(buildRequestRuleExpr(nextGroups));
}, [onRequestRuleExprChange]);
useEffect(() => {
const parsed = tryParseVisualConfig(currentExpr);
if (parsed) {
setEditorMode('visual');
setVisualConfig(parsed);
setRawExpr(currentExpr);
} else if (currentExpr) {
setEditorMode('raw');
setRawExpr(currentExpr);
setVisualConfig(null);
} else {
setEditorMode('visual');
setVisualConfig(createDefaultVisualConfig());
setRawExpr('');
}
}, [model?.name]);
const effectiveExpr = useMemo(() => {
if (editorMode === 'visual') {
return generateExprFromVisualConfig(visualConfig);
}
const { billingExpr } = splitBillingExprAndRequestRules(rawExpr);
return billingExpr;
}, [editorMode, visualConfig, rawExpr]);
useEffect(() => {
if (effectiveExpr !== currentExpr) {
onExprChange(effectiveExpr);
}
}, [effectiveExpr]);
const handleVisualChange = useCallback((newConfig) => {
setVisualConfig(newConfig);
}, []);
const handleRawChange = useCallback((val) => {
setRawExpr(val);
const { requestRuleExpr: ruleStr } = splitBillingExprAndRequestRules(val);
onRequestRuleExprChange(ruleStr);
}, [onRequestRuleExprChange]);
const handleModeSwitch = useCallback(
(e) => {
const newMode = e.target.value;
if (newMode === 'visual') {
const { billingExpr, requestRuleExpr: ruleStr } = splitBillingExprAndRequestRules(rawExpr);
const parsed = tryParseVisualConfig(billingExpr);
if (parsed) {
setVisualConfig(parsed);
} else {
setVisualConfig(createDefaultVisualConfig());
}
const parsedGroups = tryParseRequestRuleExpr(ruleStr);
setRequestRuleGroups(parsedGroups || []);
onRequestRuleExprChange(ruleStr);
} else {
const expr = generateExprFromVisualConfig(visualConfig);
const ruleExpr = buildRequestRuleExpr(requestRuleGroups);
setRawExpr(combineBillingExpr(expr, ruleExpr) || expr);
}
setEditorMode(newMode);
},
[rawExpr, visualConfig, requestRuleGroups, onRequestRuleExprChange],
);
const applyPreset = useCallback(
(preset) => {
const presetGroups = preset.requestRules || [];
const ruleExpr = buildRequestRuleExpr(presetGroups);
const combined = combineBillingExpr(preset.expr, ruleExpr) || preset.expr;
setRawExpr(combined);
const parsed = tryParseVisualConfig(preset.expr);
if (parsed) {
setVisualConfig(parsed);
} else {
setEditorMode('raw');
setVisualConfig(null);
}
setRequestRuleGroups(presetGroups);
onRequestRuleExprChange(ruleExpr);
},
[onRequestRuleExprChange],
);
const extraTokenValues = {
cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens,
imageTokens, imageOutputTokens, audioInputTokens, audioOutputTokens,
};
const extraTokenSetters = {
cacheReadTokens: setCacheReadTokens, cacheCreateTokens: setCacheCreateTokens,
cacheCreate1hTokens: setCacheCreate1hTokens, imageTokens: setImageTokens,
imageOutputTokens: setImageOutputTokens, audioInputTokens: setAudioInputTokens,
audioOutputTokens: setAudioOutputTokens,
};
const evalResult = useMemo(() => {
const result = evalExprLocally(effectiveExpr, promptTokens, completionTokens, extraTokenValues);
if (!result.error) {
result.cost = result.cost / 1000000 * (parseFloat(localStorage.getItem('quota_per_unit')) || 500000);
}
return result;
},
[effectiveExpr, promptTokens, completionTokens,
cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens,
imageTokens, imageOutputTokens, audioInputTokens, audioOutputTokens],
);
return (
<div>
<div style={{ marginBottom: 12 }}>
<RadioGroup
type='button'
size='small'
value={editorMode}
onChange={handleModeSwitch}
>
<Radio value='visual'>{t('可视化编辑')}</Radio>
<Radio value='raw'>{t('表达式编辑')}</Radio>
</RadioGroup>
</div>
<PresetSection applyPreset={applyPreset} t={t} />
<Card
bodyStyle={{ padding: 16 }}
style={{ marginBottom: 12, background: 'var(--semi-color-fill-0)' }}
>
{editorMode === 'visual' ? (
<VisualEditor
visualConfig={visualConfig}
onChange={handleVisualChange}
t={t}
/>
) : (
<RawExprEditor exprString={rawExpr} onChange={handleRawChange} t={t} />
)}
{editorMode === 'visual' && (
<>
<div style={{ borderTop: '1px solid var(--semi-color-border)', margin: '16px 0' }} />
<div className='font-medium mb-2'>{t('请求条件调价')}</div>
<div style={{ marginBottom: 12 }}>
<Text type='secondary' size='small'>
{t('满足条件时,整单价格乘以 X如果有多条同时命中会继续相乘。')}
</Text>
<div style={{ marginTop: 2 }}>
<Text type='secondary' size='small'>
{t('X 也可以小于 1当折扣用。想做"只给输出加价"或"额外加固定费用",请直接写完整计费公式。')}
</Text>
</div>
</div>
{currentRequestRuleExpr && !canUseVisualRules ? (
<Banner
type='warning'
bordered
fullMode={false}
closeIcon={null}
style={{ marginBottom: 12 }}
title={t('这个公式比较复杂,下面的简化表单没法完整还原,请在表达式编辑模式下修改。')}
/>
) : (
<>
{requestRuleGroups.map((group, gi) => (
<RuleGroupCard
key={`rule-group-${gi}`}
group={group}
index={gi}
t={t}
onChange={(nextGroup) => {
const next = [...requestRuleGroups];
next[gi] = nextGroup;
handleRequestRuleGroupsChange(next);
}}
onRemove={() => {
handleRequestRuleGroupsChange(requestRuleGroups.filter((_, i) => i !== gi));
}}
/>
))}
<Button
icon={<IconPlus />}
size='small'
theme='light'
onClick={() => handleRequestRuleGroupsChange([...requestRuleGroups, createEmptyRuleGroup()])}
style={{ marginTop: 4 }}
>
{t('添加条件组')}
</Button>
</>
)}
</>
)}
</Card>
<Card
bodyStyle={{ padding: 16 }}
style={{ marginBottom: 12, background: 'var(--semi-color-fill-0)' }}
>
<div className='font-medium mb-2'>{t('Token 估算器')}</div>
<div className='text-xs text-gray-500 mb-3'>
{t('输入 Token 数量,查看按当前配置的预计费用(不含分组倍率)。')}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 12,
marginBottom: 12,
}}
>
<div>
<Text size='small' className='mb-1' style={{ display: 'block' }}>
{t('输入 Token 数')} (p)
</Text>
<InputNumber
value={promptTokens}
min={0}
onChange={(val) => setPromptTokens(val ?? 0)}
style={{ width: '100%' }}
/>
</div>
<div>
<Text size='small' className='mb-1' style={{ display: 'block' }}>
{t('输出 Token 数')} (c)
</Text>
<InputNumber
value={completionTokens}
min={0}
onChange={(val) => setCompletionTokens(val ?? 0)}
style={{ width: '100%' }}
/>
</div>
</div>
{/* Cache token inputs — shown when expression uses cache variables */}
<CacheTokenEstimatorInputs
effectiveExpr={effectiveExpr}
extraTokenValues={extraTokenValues}
extraTokenSetters={extraTokenSetters}
t={t}
/>
<div
style={{
padding: '10px 14px',
borderRadius: 8,
background: evalResult.error
? 'var(--semi-color-danger-light-default)'
: 'var(--semi-color-primary-light-default)',
border: `1px solid ${evalResult.error ? 'var(--semi-color-danger)' : 'var(--semi-color-primary)'}`,
}}
>
{evalResult.error ? (
<Text type='danger'>
{t('表达式错误')}: {evalResult.error}
</Text>
) : (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text strong style={{ fontSize: 15 }}>
{t('预计费用')}{renderQuota(evalResult.cost, 4)}
</Text>
{evalResult.matchedTier && (
<Tag size='small' color='blue' type='light'>
{t('命中档位')}{evalResult.matchedTier}
</Tag>
)}
</div>
<Text
size='small'
style={{
display: 'block',
marginTop: 2,
color: 'var(--semi-color-text-3)',
}}
>
{t('原始额度')}{evalResult.cost.toLocaleString()}
</Text>
</div>
)}
</div>
</Card>
<LlmPromptHelper t={t} model={model} />
</div>
);
}