feat: implement AI gateway phase one runtime

This commit is contained in:
2026-05-09 21:18:32 +08:00
parent a5e66e79cd
commit fdcdcd477b
46 changed files with 5678 additions and 768 deletions
@@ -0,0 +1,62 @@
import type { ReactNode } from 'react';
import { Calculator } from 'lucide-react';
export function PricingInlineField(props: {
label: string;
children: ReactNode;
}) {
return (
<div className="pricingInlineField">
<span>{props.label}</span>
{props.children}
</div>
);
}
export function PricingReadonlyField(props: {
label: string;
value: string;
}) {
return (
<div className="pricingInlineField pricingInlineReadonly">
<span>{props.label}</span>
<strong>{props.value}</strong>
</div>
);
}
export function unitLabel(unit: string | undefined) {
const labels: Record<string, string> = {
image: '每张图片',
'5s': '每 5 秒',
second: '每秒',
item: '每次',
'1k_tokens': '每 1K Token',
character_1k: '每 1K 字符',
};
return labels[unit ?? ''] ?? unit ?? '-';
}
export function calculatorLabel(calculatorType: string | undefined) {
const labels: Record<string, string> = {
token_usage: '按 Token 用量',
unit_weight: '按数量和参数',
duration_weight: '按时长和参数',
formula: '按公式',
};
return labels[calculatorType ?? ''] ?? calculatorType ?? '-';
}
export function PricingFormulaNote(props: {
children: ReactNode;
}) {
return (
<div className="pricingFormulaBox">
<Calculator size={16} />
<div>
<strong></strong>
<p>{props.children}</p>
</div>
</div>
);
}
@@ -1,11 +1,19 @@
import { useMemo, useState } from 'react';
import { Calculator, Plus, Trash2 } from 'lucide-react';
import { 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, PricingInlineField, PricingReadonlyField, unitLabel } from './PricingEditorControls';
type RecordValue = Record<string, unknown>;
type ModeKey = 'text' | 'image' | 'video' | 'digitalHuman' | 'speech' | 'music';
type ModeKey = 'text' | 'image' | 'video';
type ParameterGroup = {
key: string;
title: string;
defaults: RecordValue;
labels?: Record<string, string>;
locked?: boolean;
};
type ModeDefinition = {
key: ModeKey;
@@ -13,88 +21,52 @@ type ModeDefinition = {
formula: string;
match: (rule: PricingRuleInput) => boolean;
templates: (currency: string) => PricingRuleInput[];
weightGroups: Array<{ key: string; title: string; defaults: RecordValue }>;
parameterGroups: ParameterGroup[];
};
const currencies = ['resource', 'credit', 'cny', 'usd'];
const calculators = ['token_usage', 'unit_weight', 'duration_weight', 'formula'];
const statuses = ['active', 'deprecated', 'hidden'];
const modeDefinitions: ModeDefinition[] = [
{
key: 'text',
label: '大语言模型',
formula: '扣费 = 输入 Token 单价 × 输入 Token 数 / 1000 +出 Token 单价 × 输出 Token / 1000。',
label: '文本',
formula: '一次对话扣费 = 输入 Token / 1000 ×入扣费单价 + 输出 Token / 1000 × 输出扣费单价。',
match: (rule) => rule.resourceType.startsWith('text_'),
templates: (currency) => [
createRule('text_input_tokens', '文本输入 Token', 'text_input', '1k_tokens', 0.01, currency, 'token_usage', 'ceil(input_tokens / 1000) * base_price', { meter: 'input_tokens' }, {}, { metrics: ['input_tokens'], unitScale: 1000 }),
createRule('text_output_tokens', '文本输出 Token', 'text_output', '1k_tokens', 0.03, currency, 'token_usage', 'ceil(output_tokens / 1000) * base_price', { meter: 'output_tokens' }, {}, { metrics: ['output_tokens'], unitScale: 1000 }),
],
weightGroups: [],
templates: (currency) => [createTextRule(currency, 0.01, 0.03)],
parameterGroups: [],
},
{
key: 'image',
label: '图像生成',
formula: '扣费 = 基础单价 × 基础权重 × 动态参数权重,例如 2K 或高清质量会按对应倍数计费。',
label: '图像',
formula: '扣费 = 基础单价 × 数量 × 分辨率参数 × 图像质量参数。图像质量按 OpenAI GPT Image 的 low / medium / high 三档配置。',
match: (rule) => rule.resourceType === 'image' || rule.resourceType === 'image_edit',
templates: (currency) => [
createRule('image_generation', '图像生成', 'image', 'image', 10, currency, 'unit_weight', 'count * base_price * resolution_weight * quality_weight * mode_weight', { meter: 'count', base: 1 }, {
resolutionWeights: { '512x512': 0.5, '1024x1024': 1, '2K': 1.5, '4K': 2 },
qualityWeights: { standard: 1, hd: 1.5 },
modeWeights: { generation: 1 },
}, { dimensions: ['count', 'resolution', 'quality', 'mode'], defaults: { count: 1, resolution: '1024x1024', quality: 'standard', mode: 'generation' } }),
],
weightGroups: [
{ key: 'resolutionWeights', title: '分辨率', defaults: { '512x512': 0.5, '1024x1024': 1, '2K': 1.5, '4K': 2 } },
{ key: 'qualityWeights', title: '图像质量', defaults: { standard: 1, hd: 1.5 } },
{ key: 'referenceImageWeights', title: '参考图数量', defaults: { single: 1, multiple: 1.3 } },
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: '扣费 = 基础单价 × 基础权重 × 动态参数权重。例如生成 1080p 且带音频时,会叠加分辨率音频权重。',
label: '视频',
formula: '扣费 = 基础单价 × 生成时长单位 × 数量 × 分辨率音频、参考视频、音色等计费参数。',
match: (rule) => rule.resourceType === 'video',
templates: (currency) => [
createRule('video_generation', '视频生成', 'video', '5s', 100, currency, 'duration_weight', 'count * ceil(duration_seconds / 5) * base_price * resolution_weight * audio_weight * reference_video_weight * voice_specified_weight', { meter: 'duration', unitSeconds: 5, base: 1 }, {
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 } }),
],
weightGroups: [
{ key: 'resolutionWeights', title: '分辨率', defaults: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 } },
{ key: 'audioWeights', title: '是否生成音频', defaults: { true: 2, false: 1 } },
{ key: 'referenceVideoWeights', title: '是否含有参考视频', defaults: { true: 1.5, false: 1 } },
{ key: 'voiceSpecifiedWeights', title: '是否指定音色', defaults: { true: 1.2, false: 1 } },
],
},
{
key: 'digitalHuman',
label: '数字人生成',
formula: '扣费 = 基础单价 × 生成时长 × 分辨率、音频驱动等动态参数权重。',
match: (rule) => rule.resourceType === 'digital_human',
templates: (currency) => [createRule('digital_human_generation', '数字人生成', 'digital_human', 'second', 2, currency, 'duration_weight', 'duration_seconds * base_price * resolution_weight', { meter: 'duration_seconds', base: 1 }, { resolutionWeights: { '720p': 1, '1080p': 1.5 } }, { dimensions: ['duration_seconds', 'resolution'], defaults: { duration_seconds: 10, resolution: '720p' } })],
weightGroups: [{ key: 'resolutionWeights', title: '分辨率', defaults: { '720p': 1, '1080p': 1.5 } }],
},
{
key: 'speech',
label: '语音合成',
formula: '扣费 = 基础单价 × 字符数 / 1000 × 声音或质量权重。',
match: (rule) => rule.resourceType === 'audio',
templates: (currency) => [createRule('speech_generation', '语音合成', 'audio', 'character_1k', 1, currency, 'unit_weight', 'ceil(characters / 1000) * base_price * voice_weight', { meter: 'characters', base: 1 }, { voiceWeights: { standard: 1, premium: 1.5 } }, { dimensions: ['characters', 'voice'], defaults: { voice: 'standard' } })],
weightGroups: [{ key: 'voiceWeights', title: '音色', defaults: { standard: 1, premium: 1.5 } }],
},
{
key: 'music',
label: '音乐生成',
formula: '扣费 = 基础单价 × 生成数量 × 时长或质量权重。',
match: (rule) => rule.resourceType === 'music',
templates: (currency) => [createRule('music_generation', '音乐生成', 'music', 'item', 20, currency, 'unit_weight', 'count * base_price * duration_weight * quality_weight', { meter: 'count', base: 1 }, { durationWeights: { short: 1, long: 1.8 }, qualityWeights: { standard: 1, high: 1.5 } }, { dimensions: ['count', 'duration', 'quality'], defaults: { count: 1, duration: 'short', quality: 'standard' } })],
weightGroups: [
{ key: 'durationWeights', title: '时长', defaults: { short: 1, long: 1.8 } },
{ key: 'qualityWeights', title: '质量', defaults: { standard: 1, high: 1.5 } },
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 },
],
},
];
@@ -104,27 +76,26 @@ export function PricingRuleVisualEditor(props: {
rules: PricingRuleInput[];
onChange: (rules: PricingRuleInput[]) => void;
}) {
const [activeMode, setActiveMode] = useState<ModeKey>('video');
const [activeMode, setActiveMode] = useState<ModeKey>('text');
const mode = modeDefinitions.find((item) => item.key === activeMode) ?? modeDefinitions[0];
const activeRules = useMemo(() => props.rules.filter(mode.match), [mode, props.rules]);
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]);
function replaceModeRules(nextRules: PricingRuleInput[]) {
props.onChange([...props.rules.filter((rule) => !mode.match(rule)), ...nextRules]);
}
function completeMissing() {
const existing = activeRules.length ? activeRules : mode.templates(props.currency);
replaceModeRules(existing.map((rule) => completeRule(rule, mode)));
}
return (
<section className="pricingModeEditor spanTwo">
<div className="pricingModeHeader">
<div>
<strong></strong>
<span></span>
<span></span>
</div>
<Button type="button" variant="outline" size="sm" onClick={completeMissing}></Button>
</div>
<div className="pricingModeTabs">
{modeDefinitions.map((item) => (
@@ -152,6 +123,10 @@ export function PricingRuleVisualEditor(props: {
);
}
export function createDefaultPricingRules(currency: string): PricingRuleInput[] {
return modeDefinitions.flatMap((mode) => mode.templates(currency).map((rule) => completeRule(rule, mode)));
}
function ModeRuleEditor(props: {
mode: ModeDefinition;
rule: PricingRuleInput;
@@ -159,19 +134,17 @@ function ModeRuleEditor(props: {
onDelete: () => void;
}) {
const rule = props.rule;
const baseWeight = Number((rule.baseWeight ?? {}).base ?? 1);
const formula = String((rule.formulaConfig ?? {}).formula ?? props.mode.formula);
function patch(patchValue: Partial<PricingRuleInput>) {
props.onChange({ ...rule, ...patchValue });
props.onChange(completeRule({ ...rule, ...patchValue }, props.mode));
}
function patchDynamicWeight(key: string, value: RecordValue) {
patch({ dynamicWeight: { ...(rule.dynamicWeight ?? {}), [key]: value } });
}
function addCustomGroup() {
patchDynamicWeight(nextKey(rule.dynamicWeight ?? {}, 'customWeights'), {});
if (props.mode.key === 'text') {
return <TextRuleEditor formula={props.mode.formula} rule={rule} onChange={props.onChange} onDelete={props.onDelete} />;
}
return (
@@ -185,40 +158,33 @@ function ModeRuleEditor(props: {
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} /></Button>
</header>
<div className="pricingModeBaseRows">
<Label> Key<Input value={rule.ruleKey ?? ''} onChange={(event) => patch({ ruleKey: event.target.value })} /></Label>
<div className="pricingModeBaseRows compact">
<Label><Input value={rule.displayName ?? ''} onChange={(event) => patch({ displayName: event.target.value })} /></Label>
<Label><Input value={rule.resourceType} onChange={(event) => patch({ resourceType: event.target.value })} /></Label>
<Label><Select value={rule.calculatorType ?? 'unit_weight'} onChange={(event) => patch({ calculatorType: event.target.value })}>{calculators.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
<Label><Select value={rule.currency ?? 'resource'} onChange={(event) => patch({ currency: event.target.value })}>{currencies.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
<Label><Select value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
</div>
<div className="pricingModeInlineRows">
<div className="pricingModeInlineInput"><span>/{rule.unit}</span><Input min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} /></div>
<div className="pricingModeInlineInput"><span></span><Input min="0" step="0.01" type="number" value={baseWeight} onChange={(event) => patch({ baseWeight: { ...(rule.baseWeight ?? {}), base: Number(event.target.value) } })} /></div>
<div className="pricingModeInlineInput"><span></span><Input value={rule.unit} onChange={(event) => patch({ unit: event.target.value })} /></div>
<PricingInlineField label={`基础单价/${unitLabel(rule.unit)}`}>
<Input min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
</PricingInlineField>
<PricingReadonlyField label="计价单位" value={unitLabel(rule.unit)} />
<PricingReadonlyField label="计算方式" value={calculatorLabel(rule.calculatorType)} />
</div>
<div className="pricingFormulaBox">
<Calculator size={16} />
<div>
<strong></strong>
<Input value={formula} onChange={(event) => patch({ formulaConfig: { ...(rule.formulaConfig ?? {}), formula: event.target.value } })} />
<p>{props.mode.formula}</p>
</div>
</div>
<PricingFormulaNote>{props.mode.formula}</PricingFormulaNote>
<div className="pricingWeightStack">
{props.mode.weightGroups.map((group) => (
{props.mode.parameterGroups.map((group) => (
<WeightTable
key={group.key}
title={group.title}
value={readGroup(rule.dynamicWeight, group)}
labels={group.labels}
locked={group.locked}
onChange={(value) => patchDynamicWeight(group.key, value)}
/>
))}
{Object.entries(rule.dynamicWeight ?? {}).filter(([key]) => !props.mode.weightGroups.some((group) => group.key === key)).map(([key, value]) => (
{Object.entries(rule.dynamicWeight ?? {}).filter(([key]) => !props.mode.parameterGroups.some((group) => group.key === key)).map(([key, value]) => (
<WeightTable key={key} editableTitle title={key} value={isPlainObject(value) ? value as RecordValue : { value }} onChange={(nextValue, nextTitle) => {
const dynamicWeight = { ...(rule.dynamicWeight ?? {}) };
delete dynamicWeight[key];
@@ -227,14 +193,74 @@ function ModeRuleEditor(props: {
}} />
))}
</div>
</article>
);
}
<Button className="pricingAddWeightButton" type="button" variant="outline" onClick={addCustomGroup}><Plus size={15} /></Button>
function TextRuleEditor(props: {
formula: string;
rule: PricingRuleInput;
onChange: (rule: PricingRuleInput) => void;
onDelete: () => void;
}) {
const prices = textPrices(props.rule);
function updatePrices(inputTokenPrice: 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,
outputTokenPrice,
},
dimensionSchema: {
...(props.rule.dimensionSchema ?? {}),
metrics: ['input_tokens', 'output_tokens'],
unitScale: 1000,
},
});
}
return (
<article className="pricingModeRule pricingTextRule">
<header>
<div>
<Badge variant="secondary">text_total</Badge>
<strong>{props.rule.displayName || '文本'}</strong>
<span>text</span>
</div>
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} /></Button>
</header>
<div className="pricingModeBaseRows compact">
<Label><Input value={props.rule.displayName ?? '文本'} onChange={(event) => props.onChange({ ...props.rule, displayName: event.target.value, ruleKey: 'text' })} /></Label>
<Label><Select value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
</div>
<div className="pricingTextPriceGrid">
<PricingInlineField label="输入单价/1K tokens">
<Input min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} />
</PricingInlineField>
<PricingInlineField label="输出单价/1K tokens">
<Input min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} />
</PricingInlineField>
</div>
<PricingFormulaNote>{props.formula}</PricingFormulaNote>
</article>
);
}
function WeightTable(props: {
editableTitle?: boolean;
labels?: Record<string, string>;
locked?: boolean;
title: string;
value: RecordValue;
onChange: (value: RecordValue, title?: string) => void;
@@ -256,13 +282,17 @@ function WeightTable(props: {
</header>
<div className="pricingWeightRows">
{rows.map(([key, value], index) => (
<div className="pricingWeightRow" key={`${key}-${index}`}>
<Input value={key} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [event.target.value, value] : row)))} />
<div className={cn('pricingWeightRow', props.locked && 'locked')} key={`${key}-${index}`}>
{props.locked ? (
<span className="pricingFactorLabel">{props.labels?.[key] ?? key}</span>
) : (
<Input value={key} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [event.target.value, value] : row)))} />
)}
<Input value={scalarToString(value)} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [key, parseScalar(event.target.value)] : row)))} />
<Button type="button" variant="ghost" size="icon" onClick={() => updateRows(rows.filter((_, rowIndex) => rowIndex !== index))}><Trash2 size={14} /></Button>
{!props.locked && <Button type="button" variant="ghost" size="icon" onClick={() => updateRows(rows.filter((_, rowIndex) => rowIndex !== index))}><Trash2 size={14} /></Button>}
</div>
))}
<Button className="pricingInlineAdd" type="button" variant="outline" size="sm" onClick={() => updateRows([...rows, [nextKey(props.value, 'option'), 1]])}><Plus size={14} /></Button>
{!props.locked && <Button className="pricingInlineAdd" type="button" variant="outline" size="sm" onClick={() => updateRows([...rows, [nextKey(props.value, 'option'), 1]])}><Plus size={14} /></Button>}
</div>
</section>
);
@@ -283,20 +313,130 @@ export function KeyValueEditor(props: {
}
function completeRule(rule: PricingRuleInput, mode: ModeDefinition): PricingRuleInput {
const template = mode.templates(rule.currency ?? 'resource')[0] ?? rule;
return {
...template,
...rule,
dynamicWeight: {
...Object.fromEntries(mode.weightGroups.map((group) => [group.key, { ...group.defaults, ...(isPlainObject(rule.dynamicWeight?.[group.key]) ? rule.dynamicWeight?.[group.key] as RecordValue : {}) }])),
...(rule.dynamicWeight ?? {}),
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 readGroup(value: RecordValue | undefined, group: ModeDefinition['weightGroups'][number]) {
function normalizeDisplayName(value: string | undefined, mode: ModeDefinition) {
const legacyNames: Record<ModeKey, string[]> = {
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');
const inputRule = rules.find((rule) => rule.resourceType === 'text_input');
const outputRule = rules.find((rule) => rule.resourceType === 'text_output');
const source = totalRule ?? inputRule ?? outputRule ?? createTextRule(currency, 0.01, 0.03);
return createTextRule(
source.currency ?? currency,
Number(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;
}
function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, baseWeight: RecordValue, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput {
const textFormula = 'input_tokens / 1000 * input_token_price + output_tokens / 1000 * output_token_price';
function createTextRule(currency: string, inputTokenPrice: 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', 'output_tokens'],
unitScale: 1000,
...(source?.dimensionSchema ?? {}),
},
formulaConfig: {
...(source?.formulaConfig ?? {}),
formula: textFormula,
inputTokenPrice,
outputTokenPrice,
},
priority: source?.priority ?? 100,
status: source?.status ?? 'active',
metadata: source?.metadata ?? {},
};
}
function textPrices(rule: PricingRuleInput) {
return {
inputTokenPrice: Number(rule.formulaConfig?.inputTokenPrice ?? rule.basePrice ?? 0.01),
outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? 0.03),
};
}
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,
@@ -305,7 +445,7 @@ function createRule(ruleKey: string, displayName: string, resourceType: string,
basePrice,
currency,
calculatorType,
baseWeight,
baseWeight: {},
dynamicWeight,
dimensionSchema,
formulaConfig: { formula },
+76 -22
View File
@@ -1,9 +1,10 @@
import { useMemo, useState, type FormEvent } from 'react';
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
import { FileText, Image, Pencil, Plus, RotateCcw, Trash2, Video } from 'lucide-react';
import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
import type { LoadState } from '../../types';
import { KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor';
import { unitLabel } from './PricingEditorControls';
import { createDefaultPricingRules, KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor';
type PricingForm = {
ruleSetKey: string;
@@ -16,17 +17,6 @@ type PricingForm = {
rules: PricingRuleInput[];
};
const emptyForm: PricingForm = {
ruleSetKey: '',
name: '',
description: '',
category: 'custom',
currency: 'resource',
status: 'active',
metadata: {},
rules: [],
};
const categories = ['default', 'custom', 'provider', 'model'];
const statuses = ['active', 'deprecated', 'hidden'];
const currencies = ['resource', 'credit', 'cny', 'usd'];
@@ -40,7 +30,7 @@ export function PricingRulesPanel(props: {
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<PricingForm>(emptyForm);
const [form, setForm] = useState<PricingForm>(() => createEmptyForm());
const [localError, setLocalError] = useState('');
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
@@ -62,7 +52,7 @@ export function PricingRulesPanel(props: {
function openCreateDialog() {
setEditingId('');
setForm(emptyForm);
setForm(createEmptyForm());
setLocalError('');
setDialogOpen(true);
}
@@ -76,11 +66,15 @@ export function PricingRulesPanel(props: {
function closeDialog() {
setEditingId('');
setForm(emptyForm);
setForm(createEmptyForm());
setLocalError('');
setDialogOpen(false);
}
function updateCurrency(currency: string) {
setForm({ ...form, currency, rules: ensureRules(form.rules, currency) });
}
async function deleteRuleSet(ruleSet: PricingRuleSet) {
const confirmed = window.confirm(`确认删除定价规则 ${ruleSet.name}?已绑定的平台或模型会清空规则绑定。`);
if (!confirmed) return;
@@ -125,11 +119,14 @@ export function PricingRulesPanel(props: {
<div className="providerCatalogMeta">
<span>{ruleSet.category}</span>
<span>{ruleSet.currency}</span>
<span>{ruleSet.rules?.length ?? 0} </span>
<span>{pricingRuleSummaries(ruleSet).length} </span>
</div>
<div className="pricingRuleItems">
{(ruleSet.rules ?? []).slice(0, 5).map((rule) => (
<span key={rule.id || rule.ruleKey}>{rule.displayName || rule.resourceType}: {rule.basePrice}/{rule.unit}</span>
{pricingRuleSummaries(ruleSet).map((item) => (
<span key={item.label}>
<strong>{pricingRuleSummaryIcon(item.kind)}{item.label}</strong>
<em>{item.value}</em>
</span>
))}
</div>
<div className="providerCatalogActions">
@@ -163,7 +160,7 @@ export function PricingRulesPanel(props: {
<Label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Label>
<Label className="spanTwo"><Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
<Label><Select value={form.category} onChange={(event) => setForm({ ...form, category: event.target.value })}>{categories.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<Label><Select value={form.currency} onChange={(event) => setForm({ ...form, currency: event.target.value })}>{currencies.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<Label><Select value={form.currency} onChange={(event) => updateCurrency(event.target.value)}>{currencies.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<Label><Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{statuses.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<PricingRuleVisualEditor currency={form.currency} rules={form.rules} onChange={(rules) => setForm({ ...form, rules })} />
<KeyValueEditor className="spanTwo" title="规则集元数据" value={form.metadata} onChange={(metadata) => setForm({ ...form, metadata })} />
@@ -172,6 +169,19 @@ export function PricingRulesPanel(props: {
);
}
function createEmptyForm(currency = 'resource'): PricingForm {
return {
ruleSetKey: '',
name: '',
description: '',
category: 'custom',
currency,
status: 'active',
metadata: {},
rules: createDefaultPricingRules(currency),
};
}
function ruleSetToForm(ruleSet: PricingRuleSet): PricingForm {
return {
ruleSetKey: ruleSet.ruleSetKey,
@@ -205,13 +215,12 @@ function ruleToInput(rule: PricingRule): PricingRuleInput {
}
function formToPayload(form: PricingForm): PricingRuleSetUpsertRequest {
const rules = form.rules.map((rule, index) => ({
const rules = ensureRules(form.rules, form.currency).map((rule, index) => ({
...rule,
displayName: rule.displayName?.trim() || `${rule.resourceType} 计价`,
priority: rule.priority ?? (index + 1) * 10,
ruleKey: rule.ruleKey?.trim() || `${rule.resourceType}_${index + 1}`,
}));
if (!rules.length) throw new Error('计价规则至少需要一条');
return {
ruleSetKey: form.ruleSetKey.trim(),
name: form.name.trim(),
@@ -223,3 +232,48 @@ function formToPayload(form: PricingForm): PricingRuleSetUpsertRequest {
rules,
};
}
type PricingRuleSummary = {
kind: 'text' | 'image' | 'video' | 'custom';
label: string;
value: string;
};
function pricingRuleSummaries(ruleSet: PricingRuleSet): PricingRuleSummary[] {
const rules = ruleSet.rules ?? [];
const textTotal = rules.find((rule) => rule.resourceType === 'text_total' || rule.ruleKey === 'text');
const textInput = rules.find((rule) => rule.resourceType === 'text_input');
const textOutput = rules.find((rule) => rule.resourceType === 'text_output');
const image = rules.find((rule) => rule.resourceType === 'image');
const video = rules.find((rule) => rule.resourceType === 'video');
const items: PricingRuleSummary[] = [];
if (textTotal || textInput || textOutput) {
const inputPrice = Number(textTotal?.formulaConfig?.inputTokenPrice ?? textInput?.basePrice ?? textTotal?.basePrice ?? 0);
const outputPrice = Number(textTotal?.formulaConfig?.outputTokenPrice ?? textOutput?.basePrice ?? 0);
items.push({ kind: 'text', label: '文本', value: `输入 ${formatPrice(inputPrice)} / 输出 ${formatPrice(outputPrice)} / 1K Token` });
}
if (image) items.push({ kind: 'image', label: '图像', value: `${formatPrice(image.basePrice)} / ${unitLabel(image.unit)}` });
if (video) items.push({ kind: 'video', label: '视频', value: `${formatPrice(video.basePrice)} / ${unitLabel(video.unit)}` });
if (items.length) return items;
return rules.slice(0, 3).map((rule) => ({
kind: 'custom',
label: rule.displayName || rule.resourceType,
value: `${formatPrice(rule.basePrice)} / ${unitLabel(rule.unit)}`,
}));
}
function pricingRuleSummaryIcon(kind: PricingRuleSummary['kind']) {
if (kind === 'image') return <Image size={14} />;
if (kind === 'video') return <Video size={14} />;
return <FileText size={14} />;
}
function formatPrice(value: number) {
if (!Number.isFinite(value)) return '0';
return Number(value.toFixed(4)).toString();
}
function ensureRules(rules: PricingRuleInput[], currency: string): PricingRuleInput[] {
const sourceRules = rules.length ? rules : createDefaultPricingRules(currency);
return sourceRules.map((rule) => ({ ...rule, currency }));
}