Files
new-api/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
T
CaIon f0589cc478 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.
2026-03-17 16:59:25 +08:00

782 lines
28 KiB
React

/*
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, useMemo, useState } from 'react';
import {
Banner,
Button,
Card,
Checkbox,
Empty,
Input,
Modal,
Radio,
RadioGroup,
Space,
Switch,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
IconDelete,
IconPlus,
IconSave,
IconSearch,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import {
PAGE_SIZE,
PRICE_SUFFIX,
buildSummaryText,
hasValue,
useModelPricingEditorState,
} from '../hooks/useModelPricingEditorState';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import TieredPricingEditor from './TieredPricingEditor';
const { Text } = Typography;
const EMPTY_CANDIDATE_MODEL_NAMES = [];
const PriceInput = ({
label,
value,
placeholder,
onChange,
suffix = PRICE_SUFFIX,
disabled = false,
extraText = '',
headerAction = null,
hidden = false,
}) => (
<div style={{ marginBottom: 16 }}>
<div className='mb-1 font-medium text-gray-700 flex items-center justify-between gap-3'>
<span>{label}</span>
{headerAction}
</div>
{!hidden ? (
<Input
value={value}
placeholder={placeholder}
onChange={onChange}
suffix={suffix}
disabled={disabled}
/>
) : null}
{extraText ? (
<div className='mt-1 text-xs text-gray-500'>{extraText}</div>
) : null}
</div>
);
export default function ModelPricingEditor({
options,
refresh,
candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES,
filterMode = 'all',
allowAddModel = true,
allowDeleteModel = true,
showConflictFilter = true,
listDescription = '',
emptyTitle = '',
emptyDescription = '',
}) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [addVisible, setAddVisible] = useState(false);
const [batchVisible, setBatchVisible] = useState(false);
const [newModelName, setNewModelName] = useState('');
const {
selectedModel,
selectedModelName,
selectedModelNames,
setSelectedModelName,
setSelectedModelNames,
searchText,
setSearchText,
currentPage,
setCurrentPage,
loading,
conflictOnly,
setConflictOnly,
filteredModels,
pagedData,
selectedWarnings,
previewRows,
isOptionalFieldEnabled,
handleOptionalFieldToggle,
handleNumericFieldChange,
handleBillingModeChange,
handleBillingExprChange,
handleRequestRuleExprChange,
handleSubmit,
addModel,
deleteModel,
applySelectedModelPricing,
} = useModelPricingEditorState({
options,
refresh,
t,
candidateModelNames,
filterMode,
});
const getExprModeLabel = useCallback((model) => {
if (model?.billingMode !== 'tiered_expr') {
return '';
}
return (model.billingExpr || '').includes('tier(')
? t('阶梯计费')
: t('表达式计费');
}, [t]);
const columns = useMemo(
() => [
{
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Space>
<Button
theme='borderless'
type='tertiary'
onClick={() => setSelectedModelName(record.name)}
style={{
padding: 0,
color:
record.name === selectedModelName
? 'var(--semi-color-primary)'
: undefined,
}}
>
{text}
</Button>
{selectedModelNames.includes(record.name) ? (
<Tag color='green' shape='circle'>
{t('已勾选')}
</Tag>
) : null}
{record.hasConflict ? (
<Tag color='red' shape='circle'>
{t('矛盾')}
</Tag>
) : null}
</Space>
),
},
{
title: t('计费方式'),
dataIndex: 'billingMode',
key: 'billingMode',
render: (_, record) => (
<Tag
color={
record.billingMode === 'per-request'
? 'teal'
: record.billingMode === 'tiered_expr'
? 'amber'
: 'violet'
}
>
{record.billingMode === 'per-request'
? t('按次计费')
: record.billingMode === 'tiered_expr'
? getExprModeLabel(record)
: t('按量计费')}
</Tag>
),
},
{
title: t('价格摘要'),
dataIndex: 'summary',
key: 'summary',
render: (_, record) => buildSummaryText(record, t),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Space>
{allowDeleteModel ? (
<Button
size='small'
type='danger'
icon={<IconDelete />}
onClick={() => deleteModel(record.name)}
/>
) : null}
</Space>
),
},
],
[
allowDeleteModel,
deleteModel,
getExprModeLabel,
selectedModelName,
selectedModelNames,
setSelectedModelName,
t,
],
);
const handleAddModel = () => {
if (addModel(newModelName)) {
setNewModelName('');
setAddVisible(false);
}
};
const rowSelection = {
selectedRowKeys: selectedModelNames,
onChange: (selectedRowKeys) => setSelectedModelNames(selectedRowKeys),
};
return (
<>
<Space vertical align='start' style={{ width: '100%' }}>
<Space wrap className='mt-2'>
{allowAddModel ? (
<Button
icon={<IconPlus />}
onClick={() => setAddVisible(true)}
style={isMobile ? { width: '100%' } : undefined}
>
{t('添加模型')}
</Button>
) : null}
<Button
type='primary'
icon={<IconSave />}
loading={loading}
onClick={handleSubmit}
style={isMobile ? { width: '100%' } : undefined}
>
{t('应用更改')}
</Button>
<Button
disabled={!selectedModel || selectedModelNames.length === 0}
onClick={() => setBatchVisible(true)}
style={isMobile ? { width: '100%' } : undefined}
>
{t('批量应用当前模型价格')}
{selectedModelNames.length > 0 ? ` (${selectedModelNames.length})` : ''}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={(value) => setSearchText(value)}
style={{ width: isMobile ? '100%' : 220 }}
showClear
/>
{showConflictFilter ? (
<Checkbox
checked={conflictOnly}
onChange={(event) => setConflictOnly(event.target.checked)}
>
{t('仅显示矛盾倍率')}
</Checkbox>
) : null}
</Space>
{listDescription ? (
<div className='text-sm text-gray-500'>{listDescription}</div>
) : null}
{selectedModelNames.length > 0 ? (
<div
style={{
width: '100%',
padding: '10px 12px',
borderRadius: 8,
background: 'var(--semi-color-primary-light-default)',
border: '1px solid var(--semi-color-primary)',
color: 'var(--semi-color-primary)',
fontWeight: 600,
}}
>
{t('已勾选 {{count}} 个模型', { count: selectedModelNames.length })}
</div>
) : null}
<div
style={{
width: '100%',
display: 'grid',
gap: 16,
gridTemplateColumns: isMobile
? 'minmax(0, 1fr)'
: 'minmax(300px, 0.8fr) minmax(480px, 1.2fr)',
}}
>
<Card
bodyStyle={{ padding: 0 }}
style={isMobile ? { order: 2 } : undefined}
>
<div style={{ overflowX: 'auto' }}>
<Table
columns={columns}
dataSource={pagedData}
rowKey='name'
rowSelection={rowSelection}
pagination={{
currentPage,
pageSize: PAGE_SIZE,
total: filteredModels.length,
onPageChange: (page) => setCurrentPage(page),
showTotal: true,
showSizeChanger: false,
}}
empty={
<div style={{ textAlign: 'center', padding: '20px' }}>
{emptyTitle || t('暂无模型')}
</div>
}
onRow={(record) => ({
style: {
background: selectedModelNames.includes(record.name)
? 'var(--semi-color-success-light-default)'
: record.name === selectedModelName
? 'var(--semi-color-primary-light-default)'
: undefined,
boxShadow: selectedModelNames.includes(record.name)
? 'inset 4px 0 0 var(--semi-color-success)'
: record.name === selectedModelName
? 'inset 4px 0 0 var(--semi-color-primary)'
: undefined,
transition: 'background 0.2s ease, box-shadow 0.2s ease',
},
onClick: () => setSelectedModelName(record.name),
})}
scroll={isMobile ? { x: 720 } : undefined}
/>
</div>
</Card>
<Card
style={isMobile ? { order: 1 } : undefined}
title={selectedModel ? selectedModel.name : t('模型计费编辑器')}
headerExtraContent={
selectedModel ? (
<Tag
color={
selectedModel.billingMode === 'per-request'
? 'teal'
: selectedModel.billingMode === 'tiered_expr'
? 'amber'
: 'blue'
}
>
{selectedModel.billingMode === 'per-request'
? t('按次计费')
: selectedModel.billingMode === 'tiered_expr'
? getExprModeLabel(selectedModel)
: t('按量计费')}
</Tag>
) : null
}
>
{!selectedModel ? (
<Empty
title={emptyTitle || t('暂无模型')}
description={
emptyDescription || t('请先新增模型或从左侧列表选择一个模型')
}
/>
) : (
<div>
<div className='mb-4'>
<div className='mb-2 font-medium text-gray-700'>
{t('计费方式')}
</div>
<RadioGroup
type='button'
value={selectedModel.billingMode}
onChange={(event) => handleBillingModeChange(event.target.value)}
>
<Radio value='per-token'>{t('按量计费')}</Radio>
<Radio value='per-request'>{t('按次计费')}</Radio>
<Radio value='tiered_expr'>{t('表达式/阶梯计费')}</Radio>
</RadioGroup>
<div className='mt-2 text-xs text-gray-500'>
{t(
'普通按量/按次直接填价格就行;如果价格要跟请求参数或请求头联动,请切到表达式/阶梯计费。',
)}
</div>
</div>
{selectedWarnings.length > 0 ? (
<Card
bodyStyle={{ padding: 12 }}
style={{
marginBottom: 16,
background: 'var(--semi-color-warning-light-default)',
}}
>
<div className='font-medium mb-2'>{t('当前提示')}</div>
{selectedWarnings.map((warning) => (
<div key={warning} className='text-sm text-gray-700 mb-1'>
{warning}
</div>
))}
</Card>
) : null}
{selectedModel.billingMode === 'per-request' ? (
<PriceInput
label={t('固定价格')}
value={selectedModel.fixedPrice}
placeholder={t('输入每次调用价格')}
suffix={t('$/次')}
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
extraText={t('适合 MJ / 任务类等按次收费模型。')}
/>
) : selectedModel.billingMode === 'tiered_expr' ? (
<TieredPricingEditor
model={selectedModel}
onExprChange={handleBillingExprChange}
requestRuleExpr={selectedModel.requestRuleExpr}
onRequestRuleExprChange={handleRequestRuleExprChange}
t={t}
/>
) : (
<>
<Card
bodyStyle={{ padding: 16 }}
style={{
marginBottom: 16,
background: 'var(--semi-color-fill-0)',
}}
>
<div className='font-medium mb-3'>{t('基础价格')}</div>
<PriceInput
label={t('输入价格')}
value={selectedModel.inputPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) => handleNumericFieldChange('inputPrice', value)}
/>
{selectedModel.completionRatioLocked ? (
<Banner
type='warning'
bordered
fullMode={false}
closeIcon={null}
style={{ marginBottom: 12 }}
title={t('补全价格已锁定')}
description={t(
'该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。',
{
ratio: selectedModel.lockedCompletionRatio || '-',
},
)}
/>
) : null}
<PriceInput
label={t('补全价格')}
value={selectedModel.completionPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('completionPrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'completionPrice',
)}
disabled={selectedModel.completionRatioLocked}
onChange={(checked) =>
handleOptionalFieldToggle('completionPrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'completionPrice')
}
disabled={
!hasValue(selectedModel.inputPrice) ||
selectedModel.completionRatioLocked
}
extraText={
selectedModel.completionRatioLocked
? t(
'后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。',
{
ratio: selectedModel.lockedCompletionRatio || '-',
},
)
: !isOptionalFieldEnabled(
selectedModel,
'completionPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('缓存读取价格')}
value={selectedModel.cachePrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) => handleNumericFieldChange('cachePrice', value)}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(selectedModel, 'cachePrice')}
onChange={(checked) =>
handleOptionalFieldToggle('cachePrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'cachePrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(selectedModel, 'cachePrice')
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('缓存创建价格')}
value={selectedModel.createCachePrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('createCachePrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'createCachePrice',
)}
onChange={(checked) =>
handleOptionalFieldToggle('createCachePrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'createCachePrice')
}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'createCachePrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
</Card>
<Card
bodyStyle={{ padding: 16 }}
style={{
marginBottom: 16,
background: 'var(--semi-color-fill-0)',
}}
>
<div className='mb-3'>
<div className='font-medium'>{t('扩展价格')}</div>
<div className='text-xs text-gray-500 mt-1'>
{t('这些价格都是可选项,不填也可以。')}
</div>
</div>
<PriceInput
label={t('图片输入价格')}
value={selectedModel.imagePrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) => handleNumericFieldChange('imagePrice', value)}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(selectedModel, 'imagePrice')}
onChange={(checked) =>
handleOptionalFieldToggle('imagePrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'imagePrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(selectedModel, 'imagePrice')
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('音频输入价格')}
value={selectedModel.audioInputPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('audioInputPrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)}
onChange={(checked) =>
handleOptionalFieldToggle('audioInputPrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'audioInputPrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('音频补全价格')}
value={selectedModel.audioOutputPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('audioOutputPrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'audioOutputPrice',
)}
disabled={!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)}
onChange={(checked) =>
handleOptionalFieldToggle('audioOutputPrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'audioOutputPrice')
}
disabled={!hasValue(selectedModel.audioInputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)
? t('请先开启并填写音频输入价格。')
: !isOptionalFieldEnabled(
selectedModel,
'audioOutputPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
</Card>
</>
)}
<Card
bodyStyle={{ padding: 16 }}
style={{ background: 'var(--semi-color-fill-0)' }}
>
<div className='font-medium mb-3'>{t('保存预览')}</div>
<div className='text-xs text-gray-500 mb-3'>
{t(
'下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。',
)}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(140px, 180px) 1fr',
gap: 8,
}}
>
{previewRows.map((row) => (
<React.Fragment key={row.key}>
<Text strong>{row.label}</Text>
<Text>{row.value}</Text>
</React.Fragment>
))}
</div>
</Card>
</div>
)}
</Card>
</div>
</Space>
{allowAddModel ? (
<Modal
title={t('添加模型')}
visible={addVisible}
onCancel={() => {
setAddVisible(false);
setNewModelName('');
}}
onOk={handleAddModel}
>
<Input
value={newModelName}
placeholder={t('输入模型名称,例如 gpt-4.1')}
onChange={(value) => setNewModelName(value)}
/>
</Modal>
) : null}
<Modal
title={t('批量应用当前模型价格')}
visible={batchVisible}
onCancel={() => setBatchVisible(false)}
onOk={() => {
if (applySelectedModelPricing()) {
setBatchVisible(false);
}
}}
>
<div className='text-sm text-gray-600'>
{selectedModel
? t(
'将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。',
{
name: selectedModel.name,
count: selectedModelNames.length,
},
)
: t('请先选择一个作为模板的模型')}
</div>
{selectedModel ? (
<div className='text-xs text-gray-500 mt-3'>
{t(
'适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。',
)}
</div>
) : null}
</Modal>
</>
);
}