This commit is contained in:
2026-07-11 13:00:06 +08:00
parent 6cc1655c69
commit 344fb789a4
82 changed files with 2594 additions and 7930 deletions
@@ -1,486 +0,0 @@
import React, { useEffect } from 'react';
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Select, Space } from 'antd';
import dayjs from 'dayjs';
import type {
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
ModelPricingRule,
ModelPricingRulePayload,
} from '../../types';
interface Props {
initial?: ModelPricingRule | null;
loading?: boolean;
onSubmit: (payload: ModelPricingRulePayload) => Promise<void> | void;
onCancel: () => void;
}
const modeOptions = [
{ value: 'text_token_tiered', label: '文本分档 Token 计价' },
{ value: 'image_per_output', label: '按输出图片数量计价' },
{ value: 'image_input_output_tiered', label: '输入图片 + 输出像素分档计价' },
{ value: 'video_token_rate', label: '视频像素 Token 计价' },
];
const calculatorByMode: Record<ModelPricingBillingMode, ModelPricingCalculatorVersion> = {
text_token_tiered: 'text_token_tiered_v1',
image_per_output: 'image_per_output_v1',
image_input_output_tiered: 'image_input_output_tiered_v1',
video_token_rate: 'video_pixel_token_v1',
};
const billByOptions = [
{ value: 'successful_output_count', label: '实际成功输出数' },
{ value: 'requested_output_count', label: '请求输出数(估算)' },
{ value: 'provider_billed_count', label: '供应商明确计费数' },
];
function splitList(value?: string): string[] {
return String(value || '').split(',').map(v => v.trim()).filter(Boolean);
}
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
return source[camelKey];
}
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
const result = { ...source };
keys.forEach(key => delete result[key]);
return result;
}
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'max_context_tokens', 'maxContextTokens',
'input_rate', 'inputRate',
'audio_input_rate', 'audioInputRate',
'output_rate', 'outputRate',
'cached_input_rate', 'cachedInputRate',
'cached_audio_input_rate', 'cachedAudioInputRate',
]);
const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens');
return {
...result,
max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens,
input_rate: readAlias(value || {}, 'input_rate', 'inputRate'),
audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'),
output_rate: readAlias(value || {}, 'output_rate', 'outputRate'),
cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'),
cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'),
};
}
function normalizeOutputTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']);
const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels');
return {
...result,
max_pixels: maxPixels === undefined ? null : maxPixels,
rate: value?.rate,
};
}
function normalizeVideoRate(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'has_input_video', 'hasInputVideo',
'generate_audio', 'generateAudio',
'inference_modes', 'inferenceModes',
]);
const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo');
const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio');
const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes');
return {
...result,
...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }),
...(generateAudio === undefined ? {} : { generate_audio: generateAudio }),
...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }),
};
}
/**
* 后端规则 JSON 统一使用 snake_case;同时兼容接口层已转换成 camelCase 的历史/当前数据。
* 返回值会移除已知 camelCase 别名,避免保存时同时存在两套字段。
*/
function normalizeRuleJson(
mode: ModelPricingBillingMode,
value: Record<string, any>,
): Record<string, any> {
const source = value || {};
const base = removeKeys(source, [
'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour',
'output_rate', 'outputRate',
'bill_by', 'billBy',
'free_input_images', 'freeInputImages',
'input_image_rate', 'inputImageRate',
'output_tiers', 'outputTiers',
'token_formula', 'tokenFormula',
'default_fps', 'defaultFps',
'supported_resolutions', 'supportedResolutions',
'dimension_map', 'dimensionMap',
]);
if (mode === 'text_token_tiered') {
return {
...base,
cache_storage_rate_per_million_token_hour: readAlias(
source,
'cache_storage_rate_per_million_token_hour',
'cacheStorageRatePerMillionTokenHour',
),
tiers: (Array.isArray(source.tiers) ? source.tiers : []).map(item => normalizeTextTier(item || {})),
};
}
if (mode === 'image_per_output') {
return {
...base,
output_rate: readAlias(source, 'output_rate', 'outputRate'),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
if (mode === 'image_input_output_tiered') {
const outputTiers = readAlias(source, 'output_tiers', 'outputTiers');
return {
...base,
free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'),
input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'),
output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
return {
...base,
token_formula: readAlias(source, 'token_formula', 'tokenFormula'),
default_fps: readAlias(source, 'default_fps', 'defaultFps'),
supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'),
dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {},
rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})),
};
}
function dimensionRows(rule: Record<string, any>): Array<Record<string, any>> {
const rows: Array<Record<string, any>> = [];
Object.entries(rule.dimension_map || {}).forEach(([resolution, ratios]) => {
Object.entries((ratios || {}) as Record<string, any>).forEach(([aspectRatio, value]) => {
const item = value as Record<string, any>;
rows.push({ resolution, aspectRatio, width: item.width, height: item.height });
});
});
return rows;
}
function toFormPricing(mode: ModelPricingBillingMode, rule: Record<string, any>): Record<string, any> {
const normalized = normalizeRuleJson(mode, rule || {});
if (mode === 'text_token_tiered') {
return {
tiers: normalized.tiers || [],
cacheStorageRate: normalized.cache_storage_rate_per_million_token_hour ?? 0,
};
}
if (mode === 'image_per_output') {
return {
outputRate: normalized.output_rate ?? 0,
billBy: normalized.bill_by || 'successful_output_count',
};
}
if (mode === 'image_input_output_tiered') {
return {
freeInputImages: normalized.free_input_images ?? 1,
inputImageRate: normalized.input_image_rate ?? 0,
outputTiers: normalized.output_tiers || [],
billBy: normalized.bill_by || 'successful_output_count',
};
}
return {
tokenFormula: normalized.token_formula
|| '(input_video_seconds + output_video_seconds) * width * height * fps / 1024',
supportedResolutions: (normalized.supported_resolutions || []).join(','),
defaultFps: normalized.default_fps ?? 30,
dimensionRows: dimensionRows(normalized),
rates: (normalized.rates || []).map((r: any) => ({
...r,
resolutions: (r.resolutions || []).join(','),
inferenceModes: (r.inference_modes || []).join(','),
hasInputVideo: r.has_input_video === undefined ? 'any' : r.has_input_video ? 'true' : 'false',
generateAudio: r.generate_audio === undefined ? 'any' : r.generate_audio ? 'true' : 'false',
})),
};
}
function buildDimensionMap(rows: Array<Record<string, any>>): Record<string, any> {
const result: Record<string, any> = {};
(rows || []).forEach(row => {
const resolution = String(row.resolution || '').trim().toLowerCase();
const aspectRatio = String(row.aspectRatio || '').trim();
const width = Number(row.width || 0);
const height = Number(row.height || 0);
if (!resolution || !aspectRatio || width <= 0 || height <= 0) return;
result[resolution] ||= {};
result[resolution][aspectRatio] = { width, height };
});
return result;
}
function buildRuleJson(
mode: ModelPricingBillingMode,
pricing: Record<string, any>,
baseRule: Record<string, any>,
): Record<string, any> {
const base = normalizeRuleJson(mode, baseRule || {});
if (mode === 'text_token_tiered') {
return {
...base,
unit: 'CNY_per_million_tokens',
cache_storage_rate_per_million_token_hour: String(pricing.cacheStorageRate ?? 0),
tiers: (pricing.tiers || []).map((v: any) => ({
max_context_tokens: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === '' || readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === undefined
? null
: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens'),
input_rate: String(readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
audio_input_rate: String(readAlias(v || {}, 'audio_input_rate', 'audioInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
output_rate: String(readAlias(v || {}, 'output_rate', 'outputRate') ?? 0),
cached_input_rate: String(readAlias(v || {}, 'cached_input_rate', 'cachedInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
cached_audio_input_rate: String(
readAlias(v || {}, 'cached_audio_input_rate', 'cachedAudioInputRate')
?? readAlias(v || {}, 'cached_input_rate', 'cachedInputRate')
?? readAlias(v || {}, 'audio_input_rate', 'audioInputRate')
?? readAlias(v || {}, 'input_rate', 'inputRate')
?? 0,
),
})),
};
}
if (mode === 'image_per_output') {
return {
...base,
unit: 'CNY_per_image',
output_rate: String(pricing.outputRate ?? 0),
bill_by: pricing.billBy || 'successful_output_count',
};
}
if (mode === 'image_input_output_tiered') {
return {
...base,
unit: 'CNY_per_image',
free_input_images: Number(pricing.freeInputImages || 0),
input_image_rate: String(pricing.inputImageRate ?? 0),
output_tiers: (pricing.outputTiers || []).map((v: any) => ({
max_pixels: readAlias(v || {}, 'max_pixels', 'maxPixels') === undefined
|| readAlias(v || {}, 'max_pixels', 'maxPixels') === null
|| readAlias(v || {}, 'max_pixels', 'maxPixels') === ''
? null
: Number(readAlias(v || {}, 'max_pixels', 'maxPixels')),
rate: String(v.rate ?? 0),
})),
bill_by: pricing.billBy || 'successful_output_count',
};
}
return {
...base,
unit: 'CNY_per_million_tokens',
token_formula: '(input_video_seconds + output_video_seconds) * width * height * fps / 1024',
default_fps: Number(pricing.defaultFps || 30),
supported_resolutions: splitList(pricing.supportedResolutions).map(v => v.toLowerCase()),
dimension_map: buildDimensionMap(pricing.dimensionRows || []),
rates: (pricing.rates || []).map((v: any) => ({
...(splitList(v.resolutions).length
? { resolutions: splitList(v.resolutions).map(item => item.toLowerCase()) }
: {}),
...(v.hasInputVideo !== 'any' && v.hasInputVideo !== undefined
? { has_input_video: v.hasInputVideo === 'true' }
: {}),
...(v.generateAudio !== 'any' && v.generateAudio !== undefined
? { generate_audio: v.generateAudio === 'true' }
: {}),
...(splitList(v.inferenceModes).length
? { inference_modes: splitList(v.inferenceModes).map(item => item.toLowerCase()) }
: {}),
rate: String(v.rate ?? 0),
})),
};
}
const PricingRuleForm: React.FC<Props> = ({ initial, loading, onSubmit, onCancel }) => {
const [form] = Form.useForm();
const mode = Form.useWatch('billingMode', form) as ModelPricingBillingMode | undefined;
useEffect(() => {
const billingMode = initial?.billingMode || 'video_token_rate';
form.setFieldsValue({
provider: initial?.provider || 'volcengine',
modelName: initial?.modelName || '',
modelCategory: initial?.modelCategory || 'video',
billingMode,
calculatorVersion: initial?.calculatorVersion || calculatorByMode[billingMode],
versionCode: initial?.versionCode || `manual_${dayjs().format('YYYYMMDD_HHmmss')}`,
effectiveRange: [
dayjs(initial?.effectiveFrom || undefined),
initial?.effectiveTo ? dayjs(initial.effectiveTo) : null,
],
currency: initial?.currency || 'CNY',
ruleSchemaVersion: initial?.ruleSchemaVersion || 1,
sourceUrl: initial?.sourceUrl || 'https://www.volcengine.com/docs/82379/1544106',
sourceUpdatedAt: initial?.sourceUpdatedAt ? dayjs(initial.sourceUpdatedAt) : null,
remark: initial?.remark || '',
pricing: toFormPricing(billingMode, initial?.ruleJson || {}),
});
}, [initial, form]);
useEffect(() => {
if (!mode) return;
form.setFieldValue('calculatorVersion', calculatorByMode[mode]);
}, [mode, form]);
const submit = async (values: any) => {
const billingMode = values.billingMode as ModelPricingBillingMode;
const [from, to] = values.effectiveRange || [];
const preserveBase = initial?.billingMode === billingMode ? initial.ruleJson : {};
await onSubmit({
provider: values.provider,
model_name: values.modelName,
model_category: values.modelCategory,
billing_mode: billingMode,
calculator_version: calculatorByMode[billingMode],
version_code: values.versionCode,
effective_from: from.toISOString(),
effective_to: to ? to.toISOString() : null,
currency: values.currency || 'CNY',
rule_schema_version: Number(values.ruleSchemaVersion || initial?.ruleSchemaVersion || 1),
rule_json: buildRuleJson(billingMode, values.pricing || {}, preserveBase || {}),
source_url: values.sourceUrl || null,
source_updated_at: values.sourceUpdatedAt ? values.sourceUpdatedAt.toISOString() : null,
remark: values.remark || null,
});
};
return (
<Form form={form} layout="vertical" onFinish={submit} preserve={false}>
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="种子规则只会创建草稿。请核对真实生效时间、单价和模型输出尺寸后再发布。"
/>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="provider" label="供应商" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name="modelName" label="完整模型名称" rules={[{ required: true }]} style={{ flex: 2 }}><Input /></Form.Item>
</Space>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="modelCategory" label="模型类型" rules={[{ required: true }]} style={{ flex: 1 }}>
<Select options={[{ value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
</Form.Item>
<Form.Item name="billingMode" label="计价模式" rules={[{ required: true }]} style={{ flex: 2 }}>
<Select options={modeOptions} />
</Form.Item>
<Form.Item name="calculatorVersion" label="计算器版本" style={{ flex: 2 }}><Input disabled /></Form.Item>
</Space>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="versionCode" label="价格版本号" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name="effectiveRange" label="真实生效区间 [开始, 结束)" rules={[{ required: true }]} style={{ flex: 2 }}>
<DatePicker.RangePicker showTime allowEmpty={[false, true]} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="currency" label="币种" style={{ width: 100 }}><Input /></Form.Item>
<Form.Item name="ruleSchemaVersion" label="结构版本" style={{ width: 100 }}><InputNumber min={1} /></Form.Item>
</Space>
<Divider titlePlacement="start"></Divider>
{mode === 'text_token_tiered' && <>
<Form.Item name={['pricing', 'cacheStorageRate']} label="缓存存储单价(元/M Token·小时)">
<InputNumber min={0} stringMode style={{ width: 260 }} />
</Form.Item>
<Form.List name={['pricing', 'tiers']}>
{(fields, { add, remove }) => <>
{fields.map(field => (
<Space key={field.key} align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'max_context_tokens']} label="最大上下文 Token"><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'input_rate']} label="输入单价/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'audio_input_rate']} label="音频输入/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'cached_input_rate']} label="缓存文本/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'cached_audio_input_rate']} label="缓存音频/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'output_rate']} label="输出单价/M"><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>
))}
<Button onClick={() => add({})}> Token </Button>
</>}
</Form.List>
</>}
{mode === 'image_per_output' && <Space align="baseline">
<Form.Item name={['pricing', 'outputRate']} label="输出图片单价(元/张)" rules={[{ required: true }]}>
<InputNumber min={0} stringMode style={{ width: 220 }} />
</Form.Item>
<Form.Item name={['pricing', 'billBy']} label="计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
</Space>}
{mode === 'image_input_output_tiered' && <>
<Space align="baseline">
<Form.Item name={['pricing', 'freeInputImages']} label="免费输入图片数"><InputNumber min={0} /></Form.Item>
<Form.Item name={['pricing', 'inputImageRate']} label="超出后输入图片单价"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item name={['pricing', 'billBy']} label="输出计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
</Space>
<Form.List name={['pricing', 'outputTiers']}>
{(fields, { add, remove }) => <>
{fields.map(field => <Space key={field.key} align="baseline">
<Form.Item {...field} name={[field.name, 'max_pixels']} label="最大像素(末档留空)"><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'rate']} label="输出单价(元/张)"><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>)}
<Button onClick={() => add({ max_pixels: null, rate: 0 })}></Button>
</>}
</Form.List>
</>}
{mode === 'video_token_rate' && <>
<Form.Item name={['pricing', 'tokenFormula']} label="Token 公式说明(由计算器版本实现,不执行文本公式)"><Input disabled /></Form.Item>
<Space align="baseline" wrap>
<Form.Item name={['pricing', 'supportedResolutions']} label="支持分辨率(逗号分隔)"><Input placeholder="480p,720p" /></Form.Item>
<Form.Item name={['pricing', 'defaultFps']} label="默认 FPS"><InputNumber min={1} max={120} /></Form.Item>
</Space>
<Divider titlePlacement="start" plain> + </Divider>
<Form.List name={['pricing', 'dimensionRows']}>
{(fields, { add, remove }) => <>
{fields.map(field => <Space key={field.key} align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'resolution']} label="分辨率" rules={[{ required: true }]}><Input placeholder="720p" /></Form.Item>
<Form.Item {...field} name={[field.name, 'aspectRatio']} label="比例" rules={[{ required: true }]}><Input placeholder="9:16" /></Form.Item>
<Form.Item {...field} name={[field.name, 'width']} label="宽" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'height']} label="高" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>)}
<Button onClick={() => add({})}></Button>
</>}
</Form.List>
<Divider titlePlacement="start" plain></Divider>
<Form.List name={['pricing', 'rates']}>
{(fields, { add, remove }) => <>
{fields.map(field => <div key={field.key} style={{ border: '1px solid #eee', padding: 12, marginBottom: 12, borderRadius: 8 }}>
<Space align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'resolutions']} label="分辨率"><Input placeholder="480p,720p;可空" /></Form.Item>
<Form.Item {...field} name={[field.name, 'hasInputVideo']} label="含输入视频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
<Form.Item {...field} name={[field.name, 'generateAudio']} label="生成音频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
<Form.Item {...field} name={[field.name, 'inferenceModes']} label="推理模式"><Input placeholder="online,flex;可空" /></Form.Item>
<Form.Item {...field} name={[field.name, 'rate']} label="单价/M Token" rules={[{ required: true }]}><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>
</div>)}
<Button onClick={() => add({ hasInputVideo: 'any', generateAudio: 'any' })}></Button>
</>}
</Form.List>
</>}
<Divider titlePlacement="start"></Divider>
<Form.Item name="sourceUrl" label="官方来源 URL"><Input /></Form.Item>
<Form.Item name="sourceUpdatedAt" label="官方文档更新时间(不是价格生效时间)"><DatePicker showTime /></Form.Item>
<Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item>
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit" loading={loading}>稿</Button>
</Space>
</Form>
);
};
export default PricingRuleForm;
@@ -1,229 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Input, message, Space, Typography } from 'antd';
import { previewModelPricing } from '../../api';
import type {
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
ModelPricingPreviewResponse,
} from '../../types';
interface Props {
billingMode: ModelPricingBillingMode;
calculatorVersion: ModelPricingCalculatorVersion;
ruleJson: Record<string, any>;
}
const examples: Record<ModelPricingBillingMode, Record<string, any>> = {
text_token_tiered: {
input_tokens: 1000,
output_tokens: 300,
cached_input_tokens: 0,
audio_input_tokens: 0,
usage_source: 'provider',
},
image_per_output: {
requested_output_count: 1,
successful_output_count: 1,
provider_billed_count: 1,
usage_source: 'provider_response',
},
image_input_output_tiered: {
provider_input_image_count: 2,
requested_output_count: 1,
successful_output_count: 1,
output_items: [{ width: 2048, height: 2048, pixels: 4194304 }],
usage_source: 'provider_response',
},
video_token_rate: {
total_tokens: 1000000,
resolution: '720p',
aspect_ratio: '9:16',
has_input_video: false,
generate_audio: false,
inference_mode: 'online',
usage_source: 'provider',
},
};
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
return source[camelKey];
}
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
const result = { ...source };
keys.forEach(key => delete result[key]);
return result;
}
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'max_context_tokens', 'maxContextTokens',
'input_rate', 'inputRate',
'audio_input_rate', 'audioInputRate',
'output_rate', 'outputRate',
'cached_input_rate', 'cachedInputRate',
'cached_audio_input_rate', 'cachedAudioInputRate',
]);
const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens');
return {
...result,
max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens,
input_rate: readAlias(value || {}, 'input_rate', 'inputRate'),
audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'),
output_rate: readAlias(value || {}, 'output_rate', 'outputRate'),
cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'),
cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'),
};
}
function normalizeOutputTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']);
const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels');
return {
...result,
max_pixels: maxPixels === undefined ? null : maxPixels,
rate: value?.rate,
};
}
function normalizeVideoRate(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'has_input_video', 'hasInputVideo',
'generate_audio', 'generateAudio',
'inference_modes', 'inferenceModes',
]);
const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo');
const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio');
const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes');
return {
...result,
...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }),
...(generateAudio === undefined ? {} : { generate_audio: generateAudio }),
...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }),
};
}
/**
* API 返回层可能会把嵌套 rule_json 一并转成 camelCase。
* 后端计价器只接受 snake_case,因此试算前必须按计价模式恢复标准结构。
*/
function normalizeRuleJson(
billingMode: ModelPricingBillingMode,
value: Record<string, any>,
): Record<string, any> {
const source = value || {};
const base = removeKeys(source, [
'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour',
'output_rate', 'outputRate',
'bill_by', 'billBy',
'free_input_images', 'freeInputImages',
'input_image_rate', 'inputImageRate',
'output_tiers', 'outputTiers',
'token_formula', 'tokenFormula',
'default_fps', 'defaultFps',
'supported_resolutions', 'supportedResolutions',
'dimension_map', 'dimensionMap',
]);
if (billingMode === 'text_token_tiered') {
const tiers = Array.isArray(source.tiers) ? source.tiers : [];
return {
...base,
cache_storage_rate_per_million_token_hour: readAlias(
source,
'cache_storage_rate_per_million_token_hour',
'cacheStorageRatePerMillionTokenHour',
),
tiers: tiers.map(item => normalizeTextTier(item || {})),
};
}
if (billingMode === 'image_per_output') {
return {
...base,
output_rate: readAlias(source, 'output_rate', 'outputRate'),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
if (billingMode === 'image_input_output_tiered') {
const outputTiers = readAlias(source, 'output_tiers', 'outputTiers');
return {
...base,
free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'),
input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'),
output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
return {
...base,
token_formula: readAlias(source, 'token_formula', 'tokenFormula'),
default_fps: readAlias(source, 'default_fps', 'defaultFps'),
supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'),
dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {},
rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})),
};
}
const PricingRulePreview: React.FC<Props> = ({ billingMode, calculatorVersion, ruleJson }) => {
const initial = useMemo(() => JSON.stringify(examples[billingMode], null, 2), [billingMode]);
const [usageText, setUsageText] = useState(initial);
const [result, setResult] = useState<ModelPricingPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setUsageText(initial);
setResult(null);
}, [initial, ruleJson, calculatorVersion]);
const run = async () => {
setLoading(true);
try {
const usage = JSON.parse(usageText);
setResult(await previewModelPricing({
billing_mode: billingMode,
calculator_version: calculatorVersion,
rule_json: normalizeRuleJson(billingMode, ruleJson),
usage,
currency: 'CNY',
}));
} catch (e: any) {
message.error(e?.message || '计价试算失败');
} finally {
setLoading(false);
}
};
return <Card size="small" title="规则试算" style={{ marginTop: 16 }}>
<Typography.Text type="secondary">
{calculatorVersion}使使 Token
</Typography.Text>
<Input.TextArea
value={usageText}
onChange={e => setUsageText(e.target.value)}
rows={10}
style={{ marginTop: 10, fontFamily: 'monospace' }}
/>
<Space style={{ marginTop: 10 }}>
<Button type="primary" loading={loading} onClick={run}></Button>
</Space>
{result && <Alert
style={{ marginTop: 12 }}
type={result.isEstimated ? 'warning' : 'success'}
showIcon
message={`${result.currency} ${result.amount}${result.isEstimated ? '(估算)' : ''}`}
description={<>
<div style={{ marginBottom: 8 }}>{result.usageSource || '-'}</div>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{JSON.stringify(result.breakdown, null, 2)}</pre>
</>}
/>}
</Card>;
};
export default PricingRulePreview;