easyai-ai-gateway/apps/web/src/pages/admin/RuntimePoliciesPanel.tsx

898 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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