chore: commit pending gateway changes
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Gauge, Pencil, Plus, RotateCcw, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import type { RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Textarea } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type RuntimePolicyForm = {
|
||||
policyKey: string;
|
||||
name: string;
|
||||
description: string;
|
||||
rpm: string;
|
||||
tpm: string;
|
||||
concurrency: string;
|
||||
retryEnabled: boolean;
|
||||
retryMaxAttempts: string;
|
||||
retryAllowKeywords: string;
|
||||
retryDenyKeywords: string;
|
||||
autoDisableEnabled: boolean;
|
||||
autoDisableThreshold: string;
|
||||
autoDisableKeywords: string;
|
||||
degradeEnabled: boolean;
|
||||
degradeCooldownSeconds: string;
|
||||
degradeKeywords: string;
|
||||
metadataJson: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export function RuntimePoliciesPanel(props: {
|
||||
message: string;
|
||||
runtimePolicySets: RuntimePolicySet[];
|
||||
state: LoadState;
|
||||
onDeleteRuntimePolicySet: (policySetId: string) => Promise<void>;
|
||||
onSaveRuntimePolicySet: (input: RuntimePolicySetUpsertRequest, policySetId?: string) => Promise<void>;
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<RuntimePolicyForm>(() => createDefaultForm());
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [pendingDeletePolicy, setPendingDeletePolicy] = useState<RuntimePolicySet | null>(null);
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingId('');
|
||||
setLocalError('');
|
||||
setForm(createDefaultForm(`runtime-${Date.now().toString(36)}`));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function editPolicy(policy: RuntimePolicySet) {
|
||||
setEditingId(policy.id);
|
||||
setLocalError('');
|
||||
setForm(policyToForm(policy));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false);
|
||||
setEditingId('');
|
||||
setLocalError('');
|
||||
setForm(createDefaultForm());
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onSaveRuntimePolicySet(formToPayload(form), editingId || undefined);
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '运行策略保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePolicy(policy: RuntimePolicySet) {
|
||||
if (isDefaultPolicy(policy)) return;
|
||||
try {
|
||||
await props.onDeleteRuntimePolicySet(policy.id);
|
||||
setPendingDeletePolicy(null);
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '运行策略删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>运行策略</CardTitle>
|
||||
<p className="mutedText">统一维护限流、重试、自动禁用和优先级降级关键词;基准模型可绑定默认策略,模型可覆盖其中某几项。</p>
|
||||
</div>
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={15} />
|
||||
新增策略
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="runtimePolicyGrid">
|
||||
{props.runtimePolicySets.map((policy) => (
|
||||
<article className="runtimePolicyCard" key={policy.id}>
|
||||
<header>
|
||||
<div className="iconBox"><ShieldCheck size={18} /></div>
|
||||
<div>
|
||||
<strong>{policy.name}</strong>
|
||||
<span>{policy.policyKey}</span>
|
||||
</div>
|
||||
<Badge variant={policy.status === 'active' ? 'success' : 'secondary'}>{policy.status}</Badge>
|
||||
</header>
|
||||
{policy.description && <p>{policy.description}</p>}
|
||||
<div className="runtimePolicySummary">
|
||||
<span><Gauge size={13} />{rateLimitSummary(policy)}</span>
|
||||
<span>{retrySummary(policy)}</span>
|
||||
<span>{autoDisableSummary(policy)}</span>
|
||||
<span>{degradeSummary(policy)}</span>
|
||||
</div>
|
||||
<footer>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => editPolicy(policy)}>
|
||||
<Pencil size={14} />
|
||||
修改
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDefaultPolicy(policy)}
|
||||
title={isDefaultPolicy(policy) ? '默认运行策略不能删除' : undefined}
|
||||
onClick={() => setPendingDeletePolicy(policy)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<FormDialog
|
||||
bodyClassName="runtimePolicyFormBody"
|
||||
className="runtimePolicyDialog"
|
||||
eyebrow={editingId ? 'Edit Runtime Policy' : 'New Runtime Policy'}
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
<RotateCcw size={15} />
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={props.state === 'loading'}>
|
||||
{editingId ? <Pencil size={15} /> : <Plus size={15} />}
|
||||
{editingId ? '保存修改' : '新增策略'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
open={dialogOpen}
|
||||
title={editingId ? '编辑运行策略' : '新增运行策略'}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Label>策略 Key<Input value={form.policyKey} onChange={(event) => setForm({ ...form, policyKey: event.target.value })} /></Label>
|
||||
<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>
|
||||
|
||||
<section className="runtimePolicySection spanTwo">
|
||||
<header><strong>限流策略</strong><span>TPM / RPM / 并发</span></header>
|
||||
<div className="runtimePolicyRows">
|
||||
<Label>RPM / 分钟请求<Input value={form.rpm} inputMode="numeric" onChange={(event) => setForm({ ...form, rpm: event.target.value })} /></Label>
|
||||
<Label>TPM / 分钟 Token<Input value={form.tpm} inputMode="numeric" onChange={(event) => setForm({ ...form, tpm: event.target.value })} /></Label>
|
||||
<Label>并发请求<Input value={form.concurrency} inputMode="numeric" onChange={(event) => setForm({ ...form, concurrency: event.target.value })} /></Label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtimePolicySection spanTwo">
|
||||
<header><strong>重试策略</strong><span>允许/拒绝关键词控制是否继续尝试下一个客户端</span></header>
|
||||
<div className="runtimePolicyRows">
|
||||
<Toggle checked={form.retryEnabled} label="允许失败重试" onChange={(checked) => setForm({ ...form, retryEnabled: checked })} />
|
||||
<Label>最大尝试次数<Input value={form.retryMaxAttempts} inputMode="numeric" onChange={(event) => setForm({ ...form, retryMaxAttempts: event.target.value })} /></Label>
|
||||
<KeywordField label="允许重试关键词" value={form.retryAllowKeywords} onChange={(value) => setForm({ ...form, retryAllowKeywords: value })} />
|
||||
<KeywordField label="拒绝重试关键词" value={form.retryDenyKeywords} onChange={(value) => setForm({ ...form, retryDenyKeywords: value })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtimePolicySection spanTwo">
|
||||
<header><strong>禁用与降级</strong><span>自动禁用错误关键词、优先级降级关键词</span></header>
|
||||
<div className="runtimePolicyRows">
|
||||
<Toggle checked={form.autoDisableEnabled} label="启用自动禁用" onChange={(checked) => setForm({ ...form, autoDisableEnabled: checked })} />
|
||||
<Label>禁用触发次数<Input value={form.autoDisableThreshold} inputMode="numeric" onChange={(event) => setForm({ ...form, autoDisableThreshold: event.target.value })} /></Label>
|
||||
<KeywordField label="自动禁用关键词" value={form.autoDisableKeywords} onChange={(value) => setForm({ ...form, autoDisableKeywords: value })} />
|
||||
<Toggle checked={form.degradeEnabled} label="启用优先级降级" onChange={(checked) => setForm({ ...form, degradeEnabled: checked })} />
|
||||
<Label>降级冷却秒数<Input value={form.degradeCooldownSeconds} inputMode="numeric" onChange={(event) => setForm({ ...form, degradeCooldownSeconds: event.target.value })} /></Label>
|
||||
<KeywordField label="降级关键词" value={form.degradeKeywords} onChange={(value) => setForm({ ...form, degradeKeywords: value })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Label>状态<Input value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })} /></Label>
|
||||
<Label className="spanTwo">元数据 JSON<Textarea value={form.metadataJson} rows={4} onChange={(event) => setForm({ ...form, metadataJson: event.target.value })} /></Label>
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除策略"
|
||||
description="已绑定模型会清空策略绑定,删除后不可恢复。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingDeletePolicy)}
|
||||
title={`确认删除运行策略 ${pendingDeletePolicy?.name ?? ''}?`}
|
||||
onCancel={() => setPendingDeletePolicy(null)}
|
||||
onConfirm={() => pendingDeletePolicy ? deletePolicy(pendingDeletePolicy) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<label className="platformToggle">
|
||||
<input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.target.checked)} />
|
||||
<span><strong>{props.label}</strong></span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function KeywordField(props: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<Label className="spanTwo">
|
||||
{props.label}
|
||||
<Input value={props.value} placeholder="多个关键词用逗号或换行分隔" onChange={(event) => props.onChange(event.target.value)} />
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
function createDefaultForm(policyKey = 'default-runtime-v1'): RuntimePolicyForm {
|
||||
return {
|
||||
policyKey,
|
||||
name: policyKey === 'default-runtime-v1' ? '默认运行策略' : '',
|
||||
description: '',
|
||||
rpm: '120',
|
||||
tpm: '240000',
|
||||
concurrency: '6',
|
||||
retryEnabled: true,
|
||||
retryMaxAttempts: '2',
|
||||
retryAllowKeywords: 'rate_limit, timeout, server_error, network, 429, 5xx',
|
||||
retryDenyKeywords: 'invalid_api_key, insufficient_quota, billing_not_active, permission_denied',
|
||||
autoDisableEnabled: false,
|
||||
autoDisableThreshold: '3',
|
||||
autoDisableKeywords: 'invalid_api_key, account_deactivated, permission_denied, billing_not_active',
|
||||
degradeEnabled: true,
|
||||
degradeCooldownSeconds: '300',
|
||||
degradeKeywords: 'rate_limit, quota, timeout, temporarily_unavailable, overloaded',
|
||||
metadataJson: '{}',
|
||||
status: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
|
||||
const rateRules = Array.isArray(policy.rateLimitPolicy?.rules) ? policy.rateLimitPolicy.rules : [];
|
||||
const retry = readObject(policy.retryPolicy);
|
||||
const disable = readObject(policy.autoDisablePolicy);
|
||||
const degrade = readObject(policy.degradePolicy);
|
||||
return {
|
||||
policyKey: policy.policyKey,
|
||||
name: policy.name,
|
||||
description: policy.description ?? '',
|
||||
rpm: String(readRateLimit(rateRules, 'rpm') || ''),
|
||||
tpm: String(readRateLimit(rateRules, 'tpm_total') || ''),
|
||||
concurrency: String(readRateLimit(rateRules, 'concurrent') || ''),
|
||||
retryEnabled: readBool(retry.enabled, true),
|
||||
retryMaxAttempts: String(readNumber(retry.maxAttempts, 2)),
|
||||
retryAllowKeywords: stringifyKeywords(retry.allowKeywords),
|
||||
retryDenyKeywords: stringifyKeywords(retry.denyKeywords),
|
||||
autoDisableEnabled: readBool(disable.enabled, false),
|
||||
autoDisableThreshold: String(readNumber(disable.threshold, 3)),
|
||||
autoDisableKeywords: stringifyKeywords(disable.keywords),
|
||||
degradeEnabled: readBool(degrade.enabled, true),
|
||||
degradeCooldownSeconds: String(readNumber(degrade.cooldownSeconds, 300)),
|
||||
degradeKeywords: stringifyKeywords(degrade.keywords),
|
||||
metadataJson: JSON.stringify(policy.metadata ?? {}, null, 2),
|
||||
status: policy.status || 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(form: RuntimePolicyForm): RuntimePolicySetUpsertRequest {
|
||||
return {
|
||||
policyKey: form.policyKey.trim(),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
rateLimitPolicy: { rules: rateLimitRules(form) },
|
||||
retryPolicy: {
|
||||
enabled: form.retryEnabled,
|
||||
maxAttempts: positiveInt(form.retryMaxAttempts, 2),
|
||||
allowKeywords: parseKeywords(form.retryAllowKeywords),
|
||||
denyKeywords: parseKeywords(form.retryDenyKeywords),
|
||||
},
|
||||
autoDisablePolicy: {
|
||||
enabled: form.autoDisableEnabled,
|
||||
threshold: positiveInt(form.autoDisableThreshold, 3),
|
||||
keywords: parseKeywords(form.autoDisableKeywords),
|
||||
},
|
||||
degradePolicy: {
|
||||
enabled: form.degradeEnabled,
|
||||
cooldownSeconds: positiveInt(form.degradeCooldownSeconds, 300),
|
||||
keywords: parseKeywords(form.degradeKeywords),
|
||||
},
|
||||
metadata: parseJson(form.metadataJson),
|
||||
status: form.status.trim() || 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function rateLimitRules(form: RuntimePolicyForm) {
|
||||
return [
|
||||
limitRule('rpm', form.rpm),
|
||||
limitRule('tpm_total', form.tpm),
|
||||
limitRule('concurrent', form.concurrency, 60, 120),
|
||||
].filter((rule): rule is NonNullable<ReturnType<typeof limitRule>> => Boolean(rule));
|
||||
}
|
||||
|
||||
function limitRule(metric: string, value: string, windowSeconds = 60, leaseTtlSeconds?: number) {
|
||||
const limit = Number(value);
|
||||
if (!Number.isFinite(limit) || limit <= 0) return undefined;
|
||||
return { metric, limit, windowSeconds, leaseTtlSeconds };
|
||||
}
|
||||
|
||||
function isDefaultPolicy(policy: RuntimePolicySet) {
|
||||
return policy.policyKey === 'default-runtime-v1';
|
||||
}
|
||||
|
||||
function rateLimitSummary(policy: RuntimePolicySet) {
|
||||
const rules = Array.isArray(policy.rateLimitPolicy?.rules) ? policy.rateLimitPolicy.rules : [];
|
||||
const rpm = readRateLimit(rules, 'rpm') || '-';
|
||||
const tpm = readRateLimit(rules, 'tpm_total') || '-';
|
||||
const concurrent = readRateLimit(rules, 'concurrent') || '-';
|
||||
return `RPM ${rpm} / TPM ${tpm} / 并发 ${concurrent}`;
|
||||
}
|
||||
|
||||
function retrySummary(policy: RuntimePolicySet) {
|
||||
const retry = readObject(policy.retryPolicy);
|
||||
return readBool(retry.enabled, false) ? `重试 ${readNumber(retry.maxAttempts, 2)} 次` : '不重试';
|
||||
}
|
||||
|
||||
function autoDisableSummary(policy: RuntimePolicySet) {
|
||||
const disable = readObject(policy.autoDisablePolicy);
|
||||
return readBool(disable.enabled, false) ? `自动禁用: ${stringifyKeywords(disable.keywords) || '-'}` : '自动禁用关闭';
|
||||
}
|
||||
|
||||
function degradeSummary(policy: RuntimePolicySet) {
|
||||
const degrade = readObject(policy.degradePolicy);
|
||||
return readBool(degrade.enabled, false) ? `降级: ${stringifyKeywords(degrade.keywords) || '-'}` : '降级关闭';
|
||||
}
|
||||
|
||||
function readRateLimit(rules: unknown[], metric: string) {
|
||||
const rule = rules.find((item) => readObject(item).metric === metric);
|
||||
return readNumber(readObject(rule).limit, 0);
|
||||
}
|
||||
|
||||
function parseKeywords(value: string) {
|
||||
return value.split(/[,\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function stringifyKeywords(value: unknown) {
|
||||
return Array.isArray(value) ? value.map(String).join(', ') : '';
|
||||
}
|
||||
|
||||
function positiveInt(value: string, fallback: number) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function readBool(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function readObject(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function parseJson(value: string) {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
throw new Error('元数据 JSON 必须是对象');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user