898 lines
40 KiB
TypeScript
898 lines
40 KiB
TypeScript
import { useEffect, useState, type FormEvent } from 'react';
|
||
import { Select as AntSelect } from 'antd';
|
||
import { Gauge, Pencil, Plus, RotateCcw, Route, Save, ShieldCheck, Trash2 } from 'lucide-react';
|
||
import type { GatewayRunnerPolicy, GatewayRunnerPolicyUpsertRequest, RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
|
||
import type { LoadState } from '../../types';
|
||
|
||
type RuntimePanelTab = 'model' | 'runner';
|
||
type RunnerPolicyStrategy = 'failover' | 'hardStop' | 'priorityDemote';
|
||
|
||
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;
|
||
};
|
||
|
||
type RunnerPolicyForm = {
|
||
name: string;
|
||
description: string;
|
||
failoverEnabled: boolean;
|
||
maxPlatforms: string;
|
||
maxDurationSeconds: string;
|
||
allowCategories: string[];
|
||
denyCategories: string[];
|
||
allowCodes: string[];
|
||
denyCodes: string[];
|
||
allowKeywords: string[];
|
||
denyKeywords: string[];
|
||
allowStatusCodes: string[];
|
||
denyStatusCodes: string[];
|
||
failoverActions: Record<string, unknown>;
|
||
hardStopEnabled: boolean;
|
||
hardStopCategories: string[];
|
||
hardStopCodes: string[];
|
||
hardStopStatusCodes: string[];
|
||
hardStopKeywords: string[];
|
||
priorityDemoteEnabled: boolean;
|
||
priorityDemoteCategories: string[];
|
||
priorityDemoteCodes: string[];
|
||
priorityDemoteStatusCodes: string[];
|
||
priorityDemoteKeywords: string[];
|
||
metadataJson: string;
|
||
status: string;
|
||
};
|
||
|
||
const failoverActionDefinitions = [
|
||
{
|
||
value: 'next',
|
||
title: '仅轮转',
|
||
description: '这些错误分类只尝试下一个平台,不修改当前平台状态。',
|
||
},
|
||
{
|
||
value: 'disable_and_next',
|
||
title: '禁用后轮转',
|
||
description: '这些错误分类会先禁用当前平台,再尝试下一个平台。',
|
||
},
|
||
{
|
||
value: 'cooldown_and_next',
|
||
title: '冷却后轮转',
|
||
description: '这些错误分类会先让当前平台模型进入冷却,再尝试下一个平台。',
|
||
},
|
||
] as const;
|
||
|
||
const failoverCategoryOptions = [
|
||
'network',
|
||
'timeout',
|
||
'stream_error',
|
||
'rate_limit',
|
||
'provider_5xx',
|
||
'provider_overloaded',
|
||
'auth_error',
|
||
'request_error',
|
||
'unsupported_model',
|
||
'user_permission',
|
||
'insufficient_balance',
|
||
'client_error',
|
||
].map((item) => ({ label: item, value: item }));
|
||
|
||
export function RuntimePoliciesPanel(props: {
|
||
message: string;
|
||
runnerPolicy: GatewayRunnerPolicy | null;
|
||
runtimePolicySets: RuntimePolicySet[];
|
||
state: LoadState;
|
||
onDeleteRuntimePolicySet: (policySetId: string) => Promise<void>;
|
||
onSaveRunnerPolicy: (input: GatewayRunnerPolicyUpsertRequest) => Promise<void>;
|
||
onSaveRuntimePolicySet: (input: RuntimePolicySetUpsertRequest, policySetId?: string) => Promise<void>;
|
||
}) {
|
||
const [activeTab, setActiveTab] = useState<RuntimePanelTab>('model');
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [editingId, setEditingId] = useState('');
|
||
const [form, setForm] = useState<RuntimePolicyForm>(() => createDefaultForm());
|
||
const [runnerForm, setRunnerForm] = useState<RunnerPolicyForm>(() => runnerPolicyToForm(null));
|
||
const [localError, setLocalError] = useState('');
|
||
const [pendingDeletePolicy, setPendingDeletePolicy] = useState<RuntimePolicySet | null>(null);
|
||
|
||
useEffect(() => {
|
||
setRunnerForm(runnerPolicyToForm(props.runnerPolicy));
|
||
}, [props.runnerPolicy?.id, props.runnerPolicy?.updatedAt]);
|
||
|
||
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 : '运行策略删除失败');
|
||
}
|
||
}
|
||
|
||
async function submitRunnerPolicy(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
setLocalError('');
|
||
try {
|
||
await props.onSaveRunnerPolicy(runnerFormToPayload(runnerForm));
|
||
} catch (err) {
|
||
setLocalError(err instanceof Error ? err.message : '全局调度策略保存失败');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="pageStack">
|
||
<Card>
|
||
<CardHeader>
|
||
<div>
|
||
<CardTitle>运行策略</CardTitle>
|
||
<p className="mutedText">模型运行策略维护限流、平台内重试、自动禁用和降级;全局调度策略控制平台间切换、硬拒绝和失败平台优先级降级。</p>
|
||
</div>
|
||
{activeTab === 'model' && <Button type="button" onClick={openCreateDialog}>
|
||
<Plus size={15} />
|
||
新增策略
|
||
</Button>}
|
||
</CardHeader>
|
||
<CardContent>
|
||
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||
<Tabs
|
||
value={activeTab}
|
||
tabs={[
|
||
{ value: 'model', label: '模型运行策略', icon: <ShieldCheck size={15} /> },
|
||
{ value: 'runner', label: '全局调度策略', icon: <Route size={15} /> },
|
||
]}
|
||
onValueChange={setActiveTab}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{activeTab === 'runner' && (
|
||
<RunnerPolicyEditor
|
||
form={runnerForm}
|
||
loading={props.state === 'loading'}
|
||
onChange={setRunnerForm}
|
||
onSubmit={submitRunnerPolicy}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'model' && (
|
||
<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 RunnerPolicyEditor(props: {
|
||
form: RunnerPolicyForm;
|
||
loading: boolean;
|
||
onChange: (form: RunnerPolicyForm) => void;
|
||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||
}) {
|
||
const [activeStrategy, setActiveStrategy] = useState<RunnerPolicyStrategy>('failover');
|
||
const patch = (next: Partial<RunnerPolicyForm>) => props.onChange({ ...props.form, ...next });
|
||
const strategyDefinitions = [
|
||
{
|
||
value: 'failover' as const,
|
||
title: '平台间故障切换',
|
||
description: '当前平台内部重试耗尽后是否尝试下一个候选平台',
|
||
enabled: props.form.failoverEnabled,
|
||
icon: <Route size={15} />,
|
||
},
|
||
{
|
||
value: 'hardStop' as const,
|
||
title: '硬拒绝规则',
|
||
description: '参数、余额、权限等错误直接失败,不受模型覆盖影响',
|
||
enabled: props.form.hardStopEnabled,
|
||
icon: <ShieldCheck size={15} />,
|
||
},
|
||
{
|
||
value: 'priorityDemote' as const,
|
||
title: '优先级降级',
|
||
description: '命中后将失败平台的动态优先级调整到当前最后',
|
||
enabled: props.form.priorityDemoteEnabled,
|
||
icon: <Gauge size={15} />,
|
||
},
|
||
];
|
||
const activeDefinition = strategyDefinitions.find((item) => item.value === activeStrategy) ?? strategyDefinitions[0];
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="runnerPolicyHeader">
|
||
<div className="runnerPolicyHeaderText">
|
||
<CardTitle>{props.form.name}</CardTitle>
|
||
<p className="mutedText">{props.form.description}</p>
|
||
</div>
|
||
<Label className="runnerPolicyHeaderStatus">
|
||
状态
|
||
<Select value={props.form.status} onChange={(event) => patch({ status: event.target.value })}>
|
||
<option value="active">启用</option>
|
||
<option value="disabled">停用</option>
|
||
</Select>
|
||
</Label>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<form className="runtimePolicyFormBody runnerPolicyForm" onSubmit={props.onSubmit}>
|
||
<div className="capabilityWorkbench runnerPolicyWorkbench spanTwo">
|
||
<aside className="capabilitySidebar">
|
||
<div className="capabilitySidebarTitle">
|
||
<Route size={15} />
|
||
<strong>策略列表</strong>
|
||
</div>
|
||
<div className="capabilityList">
|
||
{strategyDefinitions.map((item) => (
|
||
<button className="capabilityListItem" data-active={item.value === activeStrategy} key={item.value} type="button" onClick={() => setActiveStrategy(item.value)}>
|
||
<span>
|
||
<strong>{item.title}</strong>
|
||
<small>{item.description}</small>
|
||
</span>
|
||
<Badge variant={item.enabled ? 'success' : 'secondary'}>{item.enabled ? '启用' : '关闭'}</Badge>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</aside>
|
||
|
||
<div className="capabilityFormPanel runnerPolicyDetailPanel">
|
||
<header>
|
||
{activeDefinition.icon}
|
||
<div>
|
||
<strong>{activeDefinition.title}</strong>
|
||
<small>{activeDefinition.description}</small>
|
||
</div>
|
||
<span>{activeDefinition.enabled ? '启用' : '关闭'}</span>
|
||
</header>
|
||
|
||
{activeStrategy === 'failover' && (
|
||
<div className="runtimePolicyRows runnerPolicyDetailRows">
|
||
<Toggle checked={props.form.failoverEnabled} label="启用平台间切换" onChange={(checked) => patch({ failoverEnabled: checked })} />
|
||
<Label>
|
||
最大平台数
|
||
<Input value={props.form.maxPlatforms} inputMode="numeric" onChange={(event) => patch({ maxPlatforms: event.target.value })} />
|
||
<span className="runtimeFieldHint">本次任务最多尝试的候选平台数量,默认 99。</span>
|
||
</Label>
|
||
<Label>
|
||
最大时间(秒)
|
||
<Input value={props.form.maxDurationSeconds} inputMode="numeric" onChange={(event) => patch({ maxDurationSeconds: event.target.value })} />
|
||
<span className="runtimeFieldHint">从任务开始执行计时,超过后不再继续重试,默认 600 秒。</span>
|
||
</Label>
|
||
<FailoverCategoryRoutingEditor
|
||
actions={props.form.failoverActions}
|
||
allowCategories={props.form.allowCategories}
|
||
denyCategories={props.form.denyCategories}
|
||
onChange={(value) => patch(value)}
|
||
/>
|
||
<div className="spanTwo runnerSupplementalRules">
|
||
<strong>补充触发条件(可自定义扩展)</strong>
|
||
<small>这些条件只决定是否进入平台间切换;分类命中后的处理方式以上面的分组为准。</small>
|
||
</div>
|
||
<KeywordField label="补充触发错误码" value={props.form.allowCodes} onChange={(value) => patch({ allowCodes: value })} />
|
||
<KeywordField label="排除触发错误码" value={props.form.denyCodes} onChange={(value) => patch({ denyCodes: value })} />
|
||
<KeywordField label="补充触发关键词" value={props.form.allowKeywords} onChange={(value) => patch({ allowKeywords: value })} />
|
||
<KeywordField label="排除触发关键词" value={props.form.denyKeywords} onChange={(value) => patch({ denyKeywords: value })} />
|
||
<KeywordField label="补充触发状态码" value={props.form.allowStatusCodes} onChange={(value) => patch({ allowStatusCodes: value })} />
|
||
<KeywordField label="排除触发状态码" value={props.form.denyStatusCodes} onChange={(value) => patch({ denyStatusCodes: value })} />
|
||
</div>
|
||
)}
|
||
|
||
{activeStrategy === 'hardStop' && (
|
||
<div className="runtimePolicyRows runnerPolicyDetailRows">
|
||
<Toggle checked={props.form.hardStopEnabled} label="启用硬拒绝" onChange={(checked) => patch({ hardStopEnabled: checked })} />
|
||
<KeywordField label="硬拒绝分类" value={props.form.hardStopCategories} onChange={(value) => patch({ hardStopCategories: value })} />
|
||
<KeywordField label="硬拒绝错误码" value={props.form.hardStopCodes} onChange={(value) => patch({ hardStopCodes: value })} />
|
||
<KeywordField label="硬拒绝状态码" value={props.form.hardStopStatusCodes} onChange={(value) => patch({ hardStopStatusCodes: value })} />
|
||
<span className="runtimeFieldHint spanTwo">建议只用错误码、分类和关键词做硬拒绝;401/403 这类平台鉴权错误默认走平台间切换。</span>
|
||
<KeywordField label="硬拒绝关键词" value={props.form.hardStopKeywords} onChange={(value) => patch({ hardStopKeywords: value })} />
|
||
</div>
|
||
)}
|
||
|
||
{activeStrategy === 'priorityDemote' && (
|
||
<div className="runtimePolicyRows runnerPolicyDetailRows">
|
||
<Toggle checked={props.form.priorityDemoteEnabled} label="启用优先级降级" onChange={(checked) => patch({ priorityDemoteEnabled: checked })} />
|
||
<span className="runtimeFieldHint spanTwo">命中降级规则后,失败平台会自动调整到当前所有平台的优先级队尾。</span>
|
||
<KeywordField label="降级分类" value={props.form.priorityDemoteCategories} onChange={(value) => patch({ priorityDemoteCategories: value })} />
|
||
<KeywordField label="降级错误码" value={props.form.priorityDemoteCodes} onChange={(value) => patch({ priorityDemoteCodes: value })} />
|
||
<KeywordField label="降级状态码" value={props.form.priorityDemoteStatusCodes} onChange={(value) => patch({ priorityDemoteStatusCodes: value })} />
|
||
<KeywordField label="降级关键词" value={props.form.priorityDemoteKeywords} onChange={(value) => patch({ priorityDemoteKeywords: value })} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<Label className="spanTwo">元数据 JSON<Textarea value={props.form.metadataJson} rows={4} onChange={(event) => patch({ metadataJson: event.target.value })} /></Label>
|
||
<div className="runtimePolicyActions spanTwo">
|
||
<Button type="submit" disabled={props.loading}>
|
||
<Save size={15} />
|
||
保存全局调度策略
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function KeywordField(props: { label: string; value: string[]; onChange: (value: string[]) => void }) {
|
||
const options = props.value.map((item) => ({ label: item, value: item }));
|
||
return (
|
||
<Label className="spanTwo runtimeTagField">
|
||
{props.label}
|
||
<AntSelect
|
||
allowClear
|
||
className="runtimeTagInput"
|
||
maxTagCount="responsive"
|
||
mode="tags"
|
||
options={options}
|
||
placeholder="输入后回车生成标签"
|
||
tokenSeparators={[',', '\n']}
|
||
value={props.value}
|
||
onChange={(value) => props.onChange(cleanTags(value))}
|
||
/>
|
||
</Label>
|
||
);
|
||
}
|
||
|
||
function FailoverCategoryRoutingEditor(props: {
|
||
actions: Record<string, unknown>;
|
||
allowCategories: string[];
|
||
denyCategories: string[];
|
||
onChange: (value: Pick<RunnerPolicyForm, 'allowCategories' | 'denyCategories' | 'failoverActions'>) => void;
|
||
}) {
|
||
const groups = failoverCategoryRoutingGroups(props.allowCategories, props.denyCategories, props.actions);
|
||
const options = categoryOptions(props.allowCategories, props.denyCategories, Object.keys(props.actions));
|
||
const updateGroup = (group: string, value: string[]) => {
|
||
props.onChange(updateFailoverCategoryRouting(props.allowCategories, props.denyCategories, props.actions, group, value));
|
||
};
|
||
return (
|
||
<div className="spanTwo runnerActionMatrix">
|
||
<div className="runnerActionIntro">
|
||
<strong>错误分类处理方式(一般保持默认无需修改)</strong>
|
||
<small>每个错误分类只放在一个分组;前三组会进入平台间切换,拒绝轮转不会尝试后续平台。</small>
|
||
</div>
|
||
<div className="runnerActionGrid">
|
||
{failoverActionDefinitions.map((action) => (
|
||
<label className="runnerActionGroup" key={action.value} data-action={action.value}>
|
||
<span>
|
||
<strong>{action.title}</strong>
|
||
<small>{action.description}</small>
|
||
</span>
|
||
<AntSelect
|
||
allowClear
|
||
className="runtimeTagInput"
|
||
maxTagCount="responsive"
|
||
mode="tags"
|
||
options={options}
|
||
placeholder="输入错误分类后回车"
|
||
tokenSeparators={[',', '\n']}
|
||
value={groups[action.value] ?? []}
|
||
onChange={(value) => updateGroup(action.value, value)}
|
||
/>
|
||
</label>
|
||
))}
|
||
<label className="runnerActionGroup" data-action="deny">
|
||
<span>
|
||
<strong>拒绝轮转</strong>
|
||
<small>这些错误分类不会尝试后续平台,当前任务直接失败。</small>
|
||
</span>
|
||
<AntSelect
|
||
allowClear
|
||
className="runtimeTagInput"
|
||
maxTagCount="responsive"
|
||
mode="tags"
|
||
options={options}
|
||
placeholder="输入错误分类后回车"
|
||
tokenSeparators={[',', '\n']}
|
||
value={groups.deny ?? []}
|
||
onChange={(value) => updateGroup('deny', value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function runnerPolicyToForm(policy: GatewayRunnerPolicy | null): RunnerPolicyForm {
|
||
const failover = readObject(policy?.failoverPolicy);
|
||
const hardStop = readObject(policy?.hardStopPolicy);
|
||
const priorityDemote = readObject(policy?.priorityDemotePolicy);
|
||
return {
|
||
name: policy?.name ?? '默认全局调度策略',
|
||
description: policy?.description ?? '控制多个候选平台之间的故障切换;模型运行策略只可覆盖 failoverPolicy,不能覆盖 hardStopPolicy。',
|
||
failoverEnabled: readBool(failover.enabled, true),
|
||
maxPlatforms: String(readNumber(failover.maxPlatforms, 99)),
|
||
maxDurationSeconds: String(readNumber(failover.maxDurationSeconds, 600)),
|
||
allowCategories: tagsFromValue(failover.allowCategories ?? ['network', 'timeout', 'stream_error', 'rate_limit', 'provider_5xx', 'provider_overloaded', 'auth_error']),
|
||
denyCategories: tagsFromValue(failover.denyCategories ?? ['request_error', 'unsupported_model', 'user_permission', 'insufficient_balance']),
|
||
allowCodes: tagsFromValue(failover.allowCodes ?? ['auth_failed', 'invalid_api_key', 'missing_credentials']),
|
||
denyCodes: tagsFromValue(failover.denyCodes ?? []),
|
||
allowKeywords: tagsFromValue(failover.allowKeywords ?? ['timeout', 'network', 'rate_limit', 'overloaded', 'temporarily_unavailable', 'server_error', 'auth_failed', 'invalid_api_key', 'missing_credentials', 'unauthorized', 'forbidden', '429', '5xx']),
|
||
denyKeywords: tagsFromValue(failover.denyKeywords ?? ['invalid_parameter', 'missing required', 'bad request']),
|
||
allowStatusCodes: tagsFromValue(failover.allowStatusCodes ?? [401, 403, 408, 429, 500, 502, 503, 504]),
|
||
denyStatusCodes: tagsFromValue(failover.denyStatusCodes ?? []),
|
||
failoverActions: Object.keys(readObject(failover.actions)).length > 0 ? readObject(failover.actions) : defaultFailoverActions(),
|
||
hardStopEnabled: readBool(hardStop.enabled, true),
|
||
hardStopCategories: tagsFromValue(hardStop.categories ?? ['request_error', 'unsupported_model', 'user_permission', 'insufficient_balance']),
|
||
hardStopCodes: tagsFromValue(hardStop.codes ?? ['bad_request', 'invalid_request', 'invalid_parameter', 'missing_required', 'unsupported_kind', 'unsupported_model', 'insufficient_balance', 'permission_denied']),
|
||
hardStopStatusCodes: tagsFromValue(hardStop.statusCodes ?? []),
|
||
hardStopKeywords: tagsFromValue(hardStop.keywords ?? ['invalid_parameter', 'missing required', 'bad request', 'insufficient balance']),
|
||
priorityDemoteEnabled: readBool(priorityDemote.enabled, true),
|
||
priorityDemoteCategories: tagsFromValue(priorityDemote.categories ?? ['network', 'timeout', 'stream_error', 'rate_limit', 'provider_5xx', 'provider_overloaded']),
|
||
priorityDemoteCodes: tagsFromValue(priorityDemote.codes ?? ['network', 'timeout', 'stream_read_error', 'rate_limit', 'server_error', 'overloaded']),
|
||
priorityDemoteStatusCodes: tagsFromValue(priorityDemote.statusCodes ?? [408, 429, 500, 502, 503, 504]),
|
||
priorityDemoteKeywords: tagsFromValue(priorityDemote.keywords ?? ['timeout', 'network', 'rate_limit', 'overloaded', 'temporarily_unavailable', 'server_error', '429', '5xx']),
|
||
metadataJson: JSON.stringify(policy?.metadata ?? {}, null, 2),
|
||
status: policy?.status ?? 'active',
|
||
};
|
||
}
|
||
|
||
function runnerFormToPayload(form: RunnerPolicyForm): GatewayRunnerPolicyUpsertRequest {
|
||
return {
|
||
policyKey: 'default-runner-v1',
|
||
name: form.name.trim() || '默认全局调度策略',
|
||
description: form.description.trim() || undefined,
|
||
failoverPolicy: {
|
||
enabled: form.failoverEnabled,
|
||
maxPlatforms: positiveInt(form.maxPlatforms, 99),
|
||
maxDurationSeconds: positiveInt(form.maxDurationSeconds, 600),
|
||
allowCategories: cleanTags(form.allowCategories),
|
||
denyCategories: cleanTags(form.denyCategories),
|
||
allowCodes: cleanTags(form.allowCodes),
|
||
denyCodes: cleanTags(form.denyCodes),
|
||
allowKeywords: cleanTags(form.allowKeywords),
|
||
denyKeywords: cleanTags(form.denyKeywords),
|
||
allowStatusCodes: parseNumberTags(form.allowStatusCodes),
|
||
denyStatusCodes: parseNumberTags(form.denyStatusCodes),
|
||
actions: normalizedFailoverActions(form),
|
||
},
|
||
hardStopPolicy: {
|
||
enabled: form.hardStopEnabled,
|
||
categories: cleanTags(form.hardStopCategories),
|
||
codes: cleanTags(form.hardStopCodes),
|
||
statusCodes: parseNumberTags(form.hardStopStatusCodes),
|
||
keywords: cleanTags(form.hardStopKeywords),
|
||
},
|
||
priorityDemotePolicy: {
|
||
enabled: form.priorityDemoteEnabled,
|
||
categories: cleanTags(form.priorityDemoteCategories),
|
||
codes: cleanTags(form.priorityDemoteCodes),
|
||
statusCodes: parseNumberTags(form.priorityDemoteStatusCodes),
|
||
keywords: cleanTags(form.priorityDemoteKeywords),
|
||
},
|
||
metadata: parseJson(form.metadataJson),
|
||
status: form.status.trim() || 'active',
|
||
};
|
||
}
|
||
|
||
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 defaultFailoverActions(): Record<string, unknown> {
|
||
return {
|
||
auth_error: 'disable_and_next',
|
||
rate_limit: 'cooldown_and_next',
|
||
provider_5xx: 'next',
|
||
request_error: 'stop',
|
||
};
|
||
}
|
||
|
||
function failoverCategoryRoutingGroups(allowCategories: string[], denyCategories: string[], actions: Record<string, unknown>) {
|
||
const actionSet = new Set(failoverActionDefinitions.map((item) => item.value));
|
||
const assignments = new Map<string, string>();
|
||
for (const category of cleanTags(allowCategories)) {
|
||
const rawAction = actions[category];
|
||
const action = typeof rawAction === 'string' ? rawAction : '';
|
||
assignments.set(category, actionSet.has(action as (typeof failoverActionDefinitions)[number]['value']) ? action : 'next');
|
||
}
|
||
for (const [category, rawAction] of Object.entries(actions)) {
|
||
if (assignments.has(category)) continue;
|
||
const action = typeof rawAction === 'string' ? rawAction : '';
|
||
if (action === 'stop') {
|
||
assignments.set(category, 'deny');
|
||
} else if (actionSet.has(action as (typeof failoverActionDefinitions)[number]['value'])) {
|
||
assignments.set(category, action);
|
||
}
|
||
}
|
||
for (const category of cleanTags(denyCategories)) {
|
||
assignments.set(category, 'deny');
|
||
}
|
||
const groups: Record<string, string[]> = {};
|
||
for (const [category, group] of assignments.entries()) {
|
||
groups[group] = [...(groups[group] ?? []), category];
|
||
}
|
||
for (const key of [...failoverActionDefinitions.map((item) => item.value), 'deny']) {
|
||
groups[key] = cleanTags(groups[key] ?? []);
|
||
}
|
||
return groups;
|
||
}
|
||
|
||
function updateFailoverCategoryRouting(
|
||
allowCategories: string[],
|
||
denyCategories: string[],
|
||
actions: Record<string, unknown>,
|
||
group: string,
|
||
value: string[],
|
||
): Pick<RunnerPolicyForm, 'allowCategories' | 'denyCategories' | 'failoverActions'> {
|
||
const groups = failoverCategoryRoutingGroups(allowCategories, denyCategories, actions);
|
||
groups[group] = cleanTags(value);
|
||
const nextActions: Record<string, unknown> = {};
|
||
const knownCategories = new Set<string>();
|
||
const nextAllowCategories: string[] = [];
|
||
for (const action of failoverActionDefinitions) {
|
||
for (const category of cleanTags(groups[action.value] ?? [])) {
|
||
knownCategories.add(category);
|
||
nextAllowCategories.push(category);
|
||
if (action.value !== 'next') {
|
||
nextActions[category] = action.value;
|
||
}
|
||
}
|
||
}
|
||
const nextDenyCategories = cleanTags(groups.deny ?? []);
|
||
for (const category of nextDenyCategories) {
|
||
knownCategories.add(category);
|
||
}
|
||
for (const [category, rawAction] of Object.entries(actions)) {
|
||
if (!knownCategories.has(category) && typeof rawAction === 'string' && rawAction !== 'next' && rawAction !== 'stop') {
|
||
nextActions[category] = rawAction;
|
||
}
|
||
}
|
||
return {
|
||
allowCategories: cleanTags(nextAllowCategories),
|
||
denyCategories: nextDenyCategories,
|
||
failoverActions: nextActions,
|
||
};
|
||
}
|
||
|
||
function normalizedFailoverActions(form: RunnerPolicyForm) {
|
||
const groups = failoverCategoryRoutingGroups(form.allowCategories, form.denyCategories, form.failoverActions);
|
||
const nextActions: Record<string, unknown> = {};
|
||
for (const action of failoverActionDefinitions) {
|
||
if (action.value === 'next') continue;
|
||
for (const category of cleanTags(groups[action.value] ?? [])) {
|
||
nextActions[category] = action.value;
|
||
}
|
||
}
|
||
return nextActions;
|
||
}
|
||
|
||
function categoryOptions(...values: string[][]) {
|
||
const knownValues = new Set(failoverCategoryOptions.map((option) => option.value));
|
||
const options = [...failoverCategoryOptions];
|
||
for (const value of values.flat()) {
|
||
const tag = String(value).trim();
|
||
if (!tag || knownValues.has(tag)) continue;
|
||
knownValues.add(tag);
|
||
options.push({ label: tag, value: tag });
|
||
}
|
||
return options;
|
||
}
|
||
|
||
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: tagsFromValue(retry.allowKeywords),
|
||
retryDenyKeywords: tagsFromValue(retry.denyKeywords),
|
||
autoDisableEnabled: readBool(disable.enabled, false),
|
||
autoDisableThreshold: String(readNumber(disable.threshold, 3)),
|
||
autoDisableKeywords: tagsFromValue(disable.keywords),
|
||
degradeEnabled: readBool(degrade.enabled, true),
|
||
degradeCooldownSeconds: String(readNumber(degrade.cooldownSeconds, 300)),
|
||
degradeKeywords: tagsFromValue(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: cleanTags(form.retryAllowKeywords),
|
||
denyKeywords: cleanTags(form.retryDenyKeywords),
|
||
},
|
||
autoDisablePolicy: {
|
||
enabled: form.autoDisableEnabled,
|
||
threshold: positiveInt(form.autoDisableThreshold, 3),
|
||
keywords: cleanTags(form.autoDisableKeywords),
|
||
},
|
||
degradePolicy: {
|
||
enabled: form.degradeEnabled,
|
||
cooldownSeconds: positiveInt(form.degradeCooldownSeconds, 300),
|
||
keywords: cleanTags(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 stringifyKeywords(value: unknown) {
|
||
return tagsFromValue(value).join(', ');
|
||
}
|
||
|
||
function tagsFromValue(value: unknown) {
|
||
if (Array.isArray(value)) return cleanTags(value.map((item) => String(item)));
|
||
return typeof value === 'string' ? cleanTags([value]) : [];
|
||
}
|
||
|
||
function cleanTags(value: string[]) {
|
||
const tags: string[] = [];
|
||
const seen = new Set<string>();
|
||
for (const raw of value) {
|
||
for (const item of raw.split(/[,\n]/)) {
|
||
const tag = item.trim();
|
||
if (!tag || seen.has(tag)) continue;
|
||
seen.add(tag);
|
||
tags.push(tag);
|
||
}
|
||
}
|
||
return tags;
|
||
}
|
||
|
||
function parseNumberTags(value: string[]) {
|
||
return cleanTags(value).map((item) => Number.parseInt(item, 10)).filter((item) => Number.isFinite(item) && item > 0);
|
||
}
|
||
|
||
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>;
|
||
}
|