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
+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 }));
}