feat: enhance tiered billing functionality and UI components

- Introduced new fields for billing mode and expression in the Pricing model.
- Implemented dynamic pricing breakdown component to display tiered billing details.
- Updated various components to support and render tiered billing information.
- Enhanced pricing calculation logic to accommodate dynamic pricing scenarios.
- Added tests for new billing expression functionalities and UI components.
This commit is contained in:
CaIon
2026-03-16 18:57:14 +08:00
parent 91ed4e196a
commit f0589cc478
23 changed files with 1237 additions and 695 deletions
@@ -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;
});