import { useMemo, useState, type ReactNode } from 'react'; import { ListChecks, Plus, Trash2 } from 'lucide-react'; import type { PricingRuleInput } from '@easyai-ai-gateway/contracts'; import { Badge, Button, Input, Label, Select } from '../../components/ui'; import { cn } from '../../lib/utils'; import { calculatorLabel, PricingFormulaNote, unitLabel } from './PricingEditorControls'; type RecordValue = Record; type ModeKey = 'text' | 'image' | 'video'; type ParameterGroup = { key: string; title: string; defaults: RecordValue; labels?: Record; locked?: boolean; }; type ModeDefinition = { key: ModeKey; label: string; formula: string; match: (rule: PricingRuleInput) => boolean; templates: (currency: string) => PricingRuleInput[]; parameterGroups: ParameterGroup[]; }; const statuses = ['active', 'deprecated', 'hidden']; const modeDefinitions: ModeDefinition[] = [ { key: 'text', label: '文本', formula: '一次对话扣费 = 未命中缓存输入 Token / 1000 × 输入扣费单价 + 命中缓存输入 Token / 1000 × 缓存输入扣费单价 + 输出 Token / 1000 × 输出扣费单价。', match: isTextPricingRule, templates: (currency) => [createTextRule(currency, 0.01, 0.01, 0.03)], parameterGroups: [], }, { key: 'image', label: '图像', formula: '扣费 = 基础单价 × 数量 × 分辨率参数 × 图像质量参数。图像质量按 OpenAI GPT Image 的 low / medium / high 三档配置。', match: (rule) => rule.resourceType === 'image' || rule.resourceType === 'image_edit', templates: (currency) => [createRule('image', '图像', 'image', 'image', 10, currency, 'unit_weight', 'count * base_price * resolution_factor * quality_factor', { resolutionFactors: { '1K': 1, '2K': 1.5, '3K': 1.75, '4K': 2, '8K': 4 }, qualityFactors: { low: 0.5, medium: 1, high: 1.5 }, }, { dimensions: ['count', 'resolution', 'quality'], defaults: { count: 1, resolution: '1K', quality: 'medium' } })], parameterGroups: [ { key: 'resolutionFactors', title: '分辨率', defaults: { '1K': 1, '2K': 1.5, '3K': 1.75, '4K': 2, '8K': 4 }, locked: true }, { key: 'qualityFactors', title: '图像质量', defaults: { low: 0.5, medium: 1, high: 1.5 }, labels: { low: '低质量 (low)', medium: '标准质量 (medium)', high: '高质量 (high)' }, locked: true }, ], }, { key: 'video', label: '视频', formula: '扣费 = 基础单价 × 生成时长单位 × 数量 × 分辨率、音频、参考视频、音色等计费参数。', match: (rule) => rule.resourceType === 'video', templates: (currency) => [ createRule('video', '视频', 'video', '5s', 100, currency, 'duration_weight', 'count * ceil(duration_seconds / 5) * base_price * resolution_factor * audio_factor * reference_video_factor * voice_specified_factor', { resolutionWeights: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 }, audioWeights: { true: 2, false: 1 }, referenceVideoWeights: { true: 1.5, false: 1 }, voiceSpecifiedWeights: { true: 1.2, false: 1 }, }, { dimensions: ['count', 'duration_seconds', 'resolution', 'audio', 'reference_video', 'voice_specified'], defaults: { count: 1, duration_seconds: 5, resolution: '720p', audio: false, reference_video: false, voice_specified: false } }), ], parameterGroups: [ { key: 'resolutionWeights', title: '分辨率', defaults: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 }, locked: true }, { key: 'audioWeights', title: '生成声音', defaults: { true: 2, false: 1 }, labels: { true: '生成声音', false: '不生成声音' }, locked: true }, { key: 'referenceVideoWeights', title: '参考视频', defaults: { true: 1.5, false: 1 }, labels: { true: '有参考视频', false: '无参考视频' }, locked: true }, { key: 'voiceSpecifiedWeights', title: '指定音色', defaults: { true: 1.2, false: 1 }, labels: { true: '指定音色', false: '不指定音色' }, locked: true }, ], }, ]; export function PricingRuleVisualEditor(props: { currency: string; rules: PricingRuleInput[]; onChange: (rules: PricingRuleInput[]) => void; }) { const [activeMode, setActiveMode] = useState('text'); const mode = modeDefinitions.find((item) => item.key === activeMode) ?? modeDefinitions[0]; const rawActiveRules = useMemo(() => props.rules.filter(mode.match), [mode, props.rules]); const activeRules = useMemo(() => { if (mode.key === 'text' && rawActiveRules.length) return [completeRule(mergeTextRules(rawActiveRules, props.currency), mode)]; if (mode.key === 'image' && rawActiveRules.length) return [completeRule(mergeTemplateRules(mode.templates(props.currency), rawActiveRules)[0], mode)]; return rawActiveRules.map((rule) => completeRule(rule, mode)); }, [mode.key, props.currency, rawActiveRules]); const modeCounts = useMemo(() => modeRuleCounts(props.rules), [props.rules]); const modeSummary = useMemo(() => summarizePricingMode(mode, activeRules), [activeRules, mode]); function replaceModeRules(nextRules: PricingRuleInput[]) { props.onChange([...props.rules.filter((rule) => !mode.match(rule)), ...nextRules]); } return (
计费模式 选择能力后维护基础价格、计费公式和计费参数。
{activeRules.length ? ( activeRules.map((rule, index) => ( replaceModeRules(activeRules.map((item, itemIndex) => (itemIndex === index ? nextRule : item)))} onDelete={() => replaceModeRules(activeRules.filter((_, itemIndex) => itemIndex !== index))} /> )) ) : (
{mode.label} 还没有计价配置
)}
); } export function createDefaultPricingRules(currency: string): PricingRuleInput[] { return modeDefinitions.flatMap((mode) => mode.templates(currency).map((rule) => completeRule(rule, mode))); } function modeRuleCounts(rules: PricingRuleInput[]): Record { return Object.fromEntries( modeDefinitions.map((mode) => [mode.key, mode.key === 'text' && rules.some(mode.match) ? 1 : rules.filter(mode.match).length]), ) as Record; } function summarizePricingMode(mode: ModeDefinition, rules: PricingRuleInput[]) { if (!rules.length) return `${mode.label}暂无计价配置,可一键添加默认规则。`; const rule = rules[0]; if (mode.key === 'text') { const prices = textPrices(rule); return `文本按输入/缓存输入/输出 Token 分别计费,输入 ${prices.inputTokenPrice}/${unitLabel(rule.unit)},缓存输入 ${prices.cachedInputTokenPrice}/${unitLabel(rule.unit)},输出 ${prices.outputTokenPrice}/${unitLabel(rule.unit)}。`; } const groupSummaries = mode.parameterGroups .map((group) => summarizeWeightGroup(group, readGroup(rule.dynamicWeight, group))) .filter(Boolean); const details = groupSummaries.length ? `\n${groupSummaries.join('\n')}` : ''; return `${mode.label}按 ${unitLabel(rule.unit)} 计费,基础单价 ${rule.basePrice},计算方式为${calculatorLabel(rule.calculatorType)}${details}。`; } function summarizeWeightGroup(group: ParameterGroup, value: RecordValue) { const items = Object.entries(value) .map(([key, weight]) => `${summaryWeightLabel(group, key)} ×${scalarToString(weight)}`) .join('、'); return items ? `${group.title}:${items}` : ''; } function summaryWeightLabel(group: ParameterGroup, key: string) { const label = group.labels?.[key] ?? key; return label.replace(/\s*\([^)]*\)\s*$/, ''); } function ModeRuleEditor(props: { mode: ModeDefinition; rule: PricingRuleInput; onChange: (rule: PricingRuleInput) => void; onDelete: () => void; }) { const rule = props.rule; function patch(patchValue: Partial) { props.onChange(completeRule({ ...rule, ...patchValue }, props.mode)); } function patchDynamicWeight(key: string, value: RecordValue) { patch({ dynamicWeight: { ...(rule.dynamicWeight ?? {}), [key]: value } }); } if (props.mode.key === 'text') { return ; } return (
{rule.resourceType} {rule.displayName || props.mode.label} {rule.ruleKey}
patch({ displayName: event.target.value })} /> patch({ basePrice: Number(event.target.value) })} /> {props.mode.formula}
{props.mode.parameterGroups.map((group) => ( patchDynamicWeight(group.key, value)} /> ))} {Object.entries(rule.dynamicWeight ?? {}).filter(([key]) => !props.mode.parameterGroups.some((group) => group.key === key)).map(([key, value]) => ( { const dynamicWeight = { ...(rule.dynamicWeight ?? {}) }; delete dynamicWeight[key]; dynamicWeight[nextTitle || key] = nextValue; patch({ dynamicWeight }); }} /> ))}
); } function TextRuleEditor(props: { formula: string; rule: PricingRuleInput; onChange: (rule: PricingRuleInput) => void; onDelete: () => void; }) { const prices = textPrices(props.rule); function updatePrices( inputTokenPrice: number, cachedInputTokenPrice: number, outputTokenPrice: number, ) { props.onChange({ ...props.rule, basePrice: inputTokenPrice, displayName: props.rule.displayName || '文本', resourceType: 'text_total', ruleKey: 'text', unit: '1k_tokens', formulaConfig: { ...(props.rule.formulaConfig ?? {}), formula: textFormula, inputTokenPrice, cachedInputTokenPrice, outputTokenPrice, }, dimensionSchema: { ...(props.rule.dimensionSchema ?? {}), metrics: [ 'input_tokens', 'uncached_input_tokens', 'cached_input_tokens', 'output_tokens', ], unitScale: 1000, }, }); } return (
text_total {props.rule.displayName || '文本'} text
props.onChange({ ...props.rule, displayName: event.target.value, ruleKey: 'text' })} /> updatePrices( Number(event.target.value), prices.cachedInputTokenPrice, prices.outputTokenPrice, ) } /> updatePrices( prices.inputTokenPrice, Number(event.target.value), prices.outputTokenPrice, ) } /> updatePrices( prices.inputTokenPrice, prices.cachedInputTokenPrice, Number(event.target.value), ) } /> {props.formula}
); } function PricingFormRows(props: { children: ReactNode }) { return
{props.children}
; } function PricingFormRow(props: { children: ReactNode; label: string }) { return (
{props.label} {props.children}
); } function PricingReadonlyRow(props: { label: string; value: string }) { return (
{props.label} {props.value}
); } function WeightTable(props: { editableTitle?: boolean; labels?: Record; locked?: boolean; title: string; value: RecordValue; onChange: (value: RecordValue, title?: string) => void; }) { const rows: Array<[string, unknown]> = Object.entries(props.value ?? {}); function updateRows(nextRows: Array<[string, unknown]>) { props.onChange(rowsToRecord(nextRows), props.title); } return (
{props.editableTitle ? ( props.onChange(props.value, event.target.value)} /> ) : ( {props.title} )}
{rows.map(([key, value], index) => (
{props.locked ? ( {props.labels?.[key] ?? key} ) : ( updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [event.target.value, value] : row)))} /> )} updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [key, parseScalar(event.target.value)] : row)))} /> {!props.locked && }
))} {!props.locked && }
); } export function KeyValueEditor(props: { className?: string; title: string; value: RecordValue; onChange: (value: RecordValue) => void; }) { return (
{props.title}
); } function completeRule(rule: PricingRuleInput, mode: ModeDefinition): PricingRuleInput { const template = mode.templates(rule.currency ?? 'resource')[0] ?? rule; return { ...template, ...rule, ruleKey: mode.key, displayName: normalizeDisplayName(rule.displayName || template.displayName, mode), resourceType: template.resourceType, dimensionSchema: template.dimensionSchema, formulaConfig: { ...(rule.formulaConfig ?? {}), formula: template.formulaConfig?.formula, }, dynamicWeight: normalizeDynamicWeight(rule.dynamicWeight, mode), }; } function normalizeDisplayName(value: string | undefined, mode: ModeDefinition) { const legacyNames: Record = { text: ['文本输入 Token', '文本输出 Token', '文本生成 Token', '文本生成'], image: ['图像生成', '图像编辑'], video: ['视频生成'], }; if (!value || legacyNames[mode.key].includes(value)) return mode.label; return value; } function normalizeDynamicWeight(value: PricingRuleInput['dynamicWeight'], mode: ModeDefinition): RecordValue { const source = isPlainObject(value) ? { ...(value as RecordValue) } : {}; if (mode.key === 'image') { if (!isPlainObject(source.resolutionFactors) && isPlainObject(source.resolutionWeights)) { source.resolutionFactors = source.resolutionWeights; } if (!isPlainObject(source.qualityFactors) && isPlainObject(source.qualityWeights)) { source.qualityFactors = source.qualityWeights; } delete source.modeFactors; delete source.modeWeights; delete source.referenceImageWeights; delete source.resolutionWeights; delete source.qualityWeights; } const groupKeys = new Set(mode.parameterGroups.map((group) => group.key)); const defaults = Object.fromEntries(mode.parameterGroups.map((group) => [ group.key, mergeGroupDefaults(group, isPlainObject(source[group.key]) ? source[group.key] as RecordValue : {}), ])); const custom = Object.fromEntries(Object.entries(source).filter(([key]) => !groupKeys.has(key))); return { ...defaults, ...custom }; } function mergeGroupDefaults(group: ParameterGroup, source: RecordValue): RecordValue { if (!group.locked) return { ...group.defaults, ...source }; return Object.fromEntries(Object.entries(group.defaults).map(([key, defaultValue]) => [ key, key in source ? source[key] : defaultValue, ])); } function mergeTemplateRules(templates: PricingRuleInput[], rules: PricingRuleInput[]) { if (templates.length === 1) return rules.length ? [{ ...templates[0], ...rules[0] }] : templates; const byKey = new Map(templates.map((rule) => [rule.ruleKey ?? rule.resourceType, rule])); for (const rule of rules) { byKey.set(rule.ruleKey ?? rule.resourceType, rule); } return Array.from(byKey.values()); } function mergeTextRules(rules: PricingRuleInput[], currency: string): PricingRuleInput { const totalRule = rules.find((rule) => rule.resourceType === 'text_total' || rule.resourceType === 'text' || rule.ruleKey === 'text'); const inputRule = rules.find((rule) => rule.resourceType === 'text_input'); const cachedInputRule = rules.find( (rule) => rule.resourceType === 'text_cached_input', ); const outputRule = rules.find((rule) => rule.resourceType === 'text_output'); const source = totalRule ?? inputRule ?? cachedInputRule ?? outputRule ?? createTextRule(currency, 0.01, 0.01, 0.03); return createTextRule( source.currency ?? currency, Number( source.formulaConfig?.inputTokenPrice ?? inputRule?.basePrice ?? source.basePrice ?? 0.01, ), Number( source.formulaConfig?.cachedInputTokenPrice ?? source.formulaConfig?.inputCacheHitTokenPrice ?? cachedInputRule?.basePrice ?? source.formulaConfig?.inputTokenPrice ?? inputRule?.basePrice ?? source.basePrice ?? 0.01, ), Number( source.formulaConfig?.outputTokenPrice ?? outputRule?.basePrice ?? 0.03, ), source, ); } function readGroup(value: RecordValue | undefined, group: ModeDefinition['parameterGroups'][number]) { return isPlainObject(value?.[group.key]) ? value?.[group.key] as RecordValue : group.defaults; } const textFormula = 'uncached_input_tokens / 1000 * input_token_price + cached_input_tokens / 1000 * cached_input_token_price + output_tokens / 1000 * output_token_price'; function createTextRule( currency: string, inputTokenPrice: number, cachedInputTokenPrice: number, outputTokenPrice: number, source?: PricingRuleInput, ): PricingRuleInput { return { ...(source ?? {}), ruleKey: 'text', displayName: normalizeDisplayName(source?.displayName, modeDefinitions[0]), resourceType: 'text_total', unit: '1k_tokens', basePrice: inputTokenPrice, currency, calculatorType: 'token_usage', baseWeight: {}, dynamicWeight: {}, dimensionSchema: { metrics: [ 'input_tokens', 'uncached_input_tokens', 'cached_input_tokens', 'output_tokens', ], unitScale: 1000, ...(source?.dimensionSchema ?? {}), }, formulaConfig: { ...(source?.formulaConfig ?? {}), formula: textFormula, inputTokenPrice, cachedInputTokenPrice, outputTokenPrice, }, priority: source?.priority ?? 100, status: source?.status ?? 'active', metadata: source?.metadata ?? {}, }; } function textPrices(rule: PricingRuleInput) { const compatRule = rule as PricingRuleInput & { cachedInputTokenPrice?: number; inputCacheHitTokenPrice?: number; inputTokenPrice?: number; outputTokenPrice?: number; }; const inputTokenPrice = Number( rule.formulaConfig?.inputTokenPrice ?? compatRule.inputTokenPrice ?? rule.basePrice ?? 0.01, ); return { inputTokenPrice, cachedInputTokenPrice: Number( rule.formulaConfig?.cachedInputTokenPrice ?? rule.formulaConfig?.inputCacheHitTokenPrice ?? compatRule.cachedInputTokenPrice ?? compatRule.inputCacheHitTokenPrice ?? inputTokenPrice, ), outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? compatRule.outputTokenPrice ?? 0.03), }; } function isTextPricingRule(rule: PricingRuleInput) { const resourceType = String(rule.resourceType ?? ''); const ruleKey = String(rule.ruleKey ?? ''); return textPricingKeys.has(resourceType) || textPricingKeys.has(ruleKey); } const textPricingKeys = new Set(['text', 'text_total', 'text_input', 'text_cached_input', 'text_output']); function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput { return { ruleKey, displayName, resourceType, unit, basePrice, currency, calculatorType, baseWeight: {}, dynamicWeight, dimensionSchema, formulaConfig: { formula }, priority: 100, status: 'active', metadata: {}, }; } function isPlainObject(value: unknown) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function scalarToString(value: unknown): string { if (value === undefined || value === null) return ''; if (typeof value === 'object') return JSON.stringify(value); return String(value); } function parseScalar(value: string): unknown { const trimmed = value.trim(); if (trimmed === '') return ''; if (trimmed === 'true') return true; if (trimmed === 'false') return false; if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { return JSON.parse(trimmed) as unknown; } catch { return value; } } return value; } function rowsToRecord(rows: Array<[string, unknown]>): RecordValue { return Object.fromEntries(rows.filter(([key]) => key.trim()).map(([key, value]) => [key.trim(), value])); } function nextKey(value: RecordValue, prefix: string) { let index = 1; while (`${prefix}${index}` in value) index += 1; return `${prefix}${index}`; }