feat: implement AI gateway phase one runtime
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user