569 lines
24 KiB
TypeScript
569 lines
24 KiB
TypeScript
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<string, unknown>;
|
||
type ModeKey = 'text' | 'image' | 'video';
|
||
type ParameterGroup = {
|
||
key: string;
|
||
title: string;
|
||
defaults: RecordValue;
|
||
labels?: Record<string, string>;
|
||
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 × 输出扣费单价。',
|
||
match: (rule) => rule.resourceType.startsWith('text_'),
|
||
templates: (currency) => [createTextRule(currency, 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<ModeKey>('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 (
|
||
<section className="pricingModeEditor spanTwo">
|
||
<div className="pricingModeHeader">
|
||
<div>
|
||
<strong>计费模式</strong>
|
||
<span>选择能力后维护基础价格、计费公式和计费参数。</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pricingModeWorkbench">
|
||
<aside className="pricingModeSidebar">
|
||
<div className="pricingModeSidebarTitle">
|
||
<ListChecks size={15} />
|
||
<strong>计费模式</strong>
|
||
</div>
|
||
<div className="pricingModeList">
|
||
{modeDefinitions.map((item) => (
|
||
<button data-active={item.key === activeMode} key={item.key} type="button" onClick={() => setActiveMode(item.key)}>
|
||
<span>
|
||
<strong>{item.label}</strong>
|
||
<small>{modeCounts[item.key] ?? 0} 条计价规则</small>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="pricingModeSummary">
|
||
<strong>规则总结</strong>
|
||
<p>{modeSummary}</p>
|
||
</div>
|
||
</aside>
|
||
|
||
<div className="pricingModePanel">
|
||
{activeRules.length ? (
|
||
activeRules.map((rule, index) => (
|
||
<ModeRuleEditor
|
||
key={`${rule.ruleKey ?? rule.resourceType}-${index}`}
|
||
mode={mode}
|
||
rule={rule}
|
||
onChange={(nextRule) => replaceModeRules(activeRules.map((item, itemIndex) => (itemIndex === index ? nextRule : item)))}
|
||
onDelete={() => replaceModeRules(activeRules.filter((_, itemIndex) => itemIndex !== index))}
|
||
/>
|
||
))
|
||
) : (
|
||
<div className="pricingModeEmpty">
|
||
<strong>{mode.label} 还没有计价配置</strong>
|
||
<Button type="button" onClick={() => replaceModeRules(mode.templates(props.currency))}><Plus size={15} />添加默认配置</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function createDefaultPricingRules(currency: string): PricingRuleInput[] {
|
||
return modeDefinitions.flatMap((mode) => mode.templates(currency).map((rule) => completeRule(rule, mode)));
|
||
}
|
||
|
||
function modeRuleCounts(rules: PricingRuleInput[]): Record<ModeKey, number> {
|
||
return Object.fromEntries(
|
||
modeDefinitions.map((mode) => [mode.key, mode.key === 'text' && rules.some(mode.match) ? 1 : rules.filter(mode.match).length]),
|
||
) as Record<ModeKey, number>;
|
||
}
|
||
|
||
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.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<PricingRuleInput>) {
|
||
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 <TextRuleEditor formula={props.mode.formula} rule={rule} onChange={props.onChange} onDelete={props.onDelete} />;
|
||
}
|
||
|
||
return (
|
||
<article className="pricingModeRule">
|
||
<header>
|
||
<div>
|
||
<Badge variant="secondary">{rule.resourceType}</Badge>
|
||
<strong>{rule.displayName || props.mode.label}</strong>
|
||
<span>{rule.ruleKey}</span>
|
||
</div>
|
||
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} />删除</Button>
|
||
</header>
|
||
|
||
<PricingFormRows>
|
||
<PricingFormRow label="展示名称">
|
||
<Input size="sm" value={rule.displayName ?? ''} onChange={(event) => patch({ displayName: event.target.value })} />
|
||
</PricingFormRow>
|
||
<PricingFormRow label="状态">
|
||
<Select size="sm" value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
||
</PricingFormRow>
|
||
<PricingFormRow label={`基础单价/${unitLabel(rule.unit)}`}>
|
||
<Input size="sm" min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
|
||
</PricingFormRow>
|
||
<PricingReadonlyRow label="计价单位" value={unitLabel(rule.unit)} />
|
||
<PricingReadonlyRow label="计算方式" value={calculatorLabel(rule.calculatorType)} />
|
||
</PricingFormRows>
|
||
|
||
<PricingFormulaNote>{props.mode.formula}</PricingFormulaNote>
|
||
|
||
<div className="pricingWeightStack">
|
||
{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.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];
|
||
dynamicWeight[nextTitle || key] = nextValue;
|
||
patch({ dynamicWeight });
|
||
}} />
|
||
))}
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
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>
|
||
|
||
<PricingFormRows>
|
||
<PricingFormRow label="展示名称">
|
||
<Input size="sm" value={props.rule.displayName ?? '文本'} onChange={(event) => props.onChange({ ...props.rule, displayName: event.target.value, ruleKey: 'text' })} />
|
||
</PricingFormRow>
|
||
<PricingFormRow label="状态">
|
||
<Select size="sm" 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>
|
||
</PricingFormRow>
|
||
<PricingFormRow label="输入单价/1K tokens">
|
||
<Input size="sm" min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} />
|
||
</PricingFormRow>
|
||
<PricingFormRow label="输出单价/1K tokens">
|
||
<Input size="sm" min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} />
|
||
</PricingFormRow>
|
||
<PricingReadonlyRow label="计价单位" value={unitLabel(props.rule.unit)} />
|
||
<PricingReadonlyRow label="计算方式" value={calculatorLabel(props.rule.calculatorType)} />
|
||
</PricingFormRows>
|
||
|
||
<PricingFormulaNote>{props.formula}</PricingFormulaNote>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function PricingFormRows(props: { children: ReactNode }) {
|
||
return <div className="pricingFormRows">{props.children}</div>;
|
||
}
|
||
|
||
function PricingFormRow(props: { children: ReactNode; label: string }) {
|
||
return (
|
||
<div className="pricingFormRow">
|
||
<strong>{props.label}</strong>
|
||
{props.children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PricingReadonlyRow(props: { label: string; value: string }) {
|
||
return (
|
||
<div className="pricingFormRow">
|
||
<strong>{props.label}</strong>
|
||
<span className="pricingFormReadonly">{props.value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WeightTable(props: {
|
||
editableTitle?: boolean;
|
||
labels?: Record<string, string>;
|
||
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 (
|
||
<section className="pricingWeightTable">
|
||
<header>
|
||
{props.editableTitle ? (
|
||
<Input value={props.title} onChange={(event) => props.onChange(props.value, event.target.value)} />
|
||
) : (
|
||
<strong>{props.title}</strong>
|
||
)}
|
||
</header>
|
||
<div className="pricingWeightRows">
|
||
{rows.map(([key, value], index) => (
|
||
<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)))} />
|
||
{!props.locked && <Button type="button" variant="ghost" size="icon" onClick={() => updateRows(rows.filter((_, rowIndex) => rowIndex !== index))}><Trash2 size={14} /></Button>}
|
||
</div>
|
||
))}
|
||
{!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>
|
||
);
|
||
}
|
||
|
||
export function KeyValueEditor(props: {
|
||
className?: string;
|
||
title: string;
|
||
value: RecordValue;
|
||
onChange: (value: RecordValue) => void;
|
||
}) {
|
||
return (
|
||
<div className={cn('pricingWeightTable', props.className)}>
|
||
<header><strong>{props.title}</strong></header>
|
||
<WeightTable title={props.title} value={props.value} onChange={props.onChange} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<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;
|
||
}
|
||
|
||
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,
|
||
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}`;
|
||
}
|