1104 lines
50 KiB
TypeScript
1104 lines
50 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 RunnerFailoverAction = 'next' | 'cooldown_and_next' | 'demote_and_next' | 'disable_and_next' | 'stop';
|
||
type RunnerFailoverTarget = 'platform' | 'model';
|
||
type RunnerActionRule = {
|
||
id: string;
|
||
enabled: boolean;
|
||
categories: string[];
|
||
codes: string[];
|
||
statusCodes: string[];
|
||
keywords: string[];
|
||
action: RunnerFailoverAction;
|
||
target: RunnerFailoverTarget;
|
||
demoteSteps: string;
|
||
cooldownSeconds: string;
|
||
};
|
||
|
||
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;
|
||
maxPlatforms: string;
|
||
maxDurationSeconds: string;
|
||
singleSourceProtectionEnabled: boolean;
|
||
actionRules: RunnerActionRule[];
|
||
cacheAffinityPolicyJson: string;
|
||
metadataJson: string;
|
||
status: string;
|
||
};
|
||
|
||
const failoverActionOptions: Array<{ label: string; value: RunnerFailoverAction }> = [
|
||
{ label: '仅重试下一个', value: 'next' },
|
||
{ label: '冷却后重试', value: 'cooldown_and_next' },
|
||
{ label: '降级后重试', value: 'demote_and_next' },
|
||
{ label: '禁用后重试', value: 'disable_and_next' },
|
||
{ label: '不再重试', value: 'stop' },
|
||
];
|
||
|
||
const failoverActionText: Record<RunnerFailoverAction, string> = {
|
||
next: '仅重试下一个',
|
||
cooldown_and_next: '冷却后重试',
|
||
demote_and_next: '降级后重试',
|
||
disable_and_next: '禁用后重试',
|
||
stop: '不再重试',
|
||
};
|
||
|
||
const failoverTargetOptions: Array<{ label: string; value: RunnerFailoverTarget }> = [
|
||
{ label: '平台', value: 'platform' },
|
||
{ label: '模型', value: 'model' },
|
||
];
|
||
|
||
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 }));
|
||
const legacyRunnerFailoverFields = ['allowCategories', 'denyCategories', 'allowCodes', 'denyCodes', 'allowKeywords', 'denyKeywords', 'allowStatusCodes', 'denyStatusCodes', 'actions'];
|
||
let runnerActionRuleId = 0;
|
||
|
||
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} placeholder="不填或负数为不限" inputMode="numeric" onChange={(event) => setForm({ ...form, rpm: event.target.value })} /></Label>
|
||
<Label>TPM / 分钟 Token<Input value={form.tpm} placeholder="不填或负数为不限" inputMode="numeric" onChange={(event) => setForm({ ...form, tpm: event.target.value })} /></Label>
|
||
<Label>并发请求<Input value={form.concurrency} placeholder="不填或负数为不限" 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 [activeRuleId, setActiveRuleId] = useState('');
|
||
const patch = (next: Partial<RunnerPolicyForm>) => props.onChange({ ...props.form, ...next });
|
||
const activeRule = props.form.actionRules.find((rule) => rule.id === activeRuleId) ?? props.form.actionRules[0] ?? null;
|
||
const activeRuleIndex = activeRule ? props.form.actionRules.findIndex((rule) => rule.id === activeRule.id) : -1;
|
||
const patchRule = (ruleId: string, next: Partial<RunnerActionRule>) => {
|
||
patch({ actionRules: props.form.actionRules.map((rule) => rule.id === ruleId ? { ...rule, ...next } : rule) });
|
||
};
|
||
const addRule = () => {
|
||
const rule = createRunnerActionRule({ categories: [], action: 'next' });
|
||
patch({ actionRules: [...props.form.actionRules, rule] });
|
||
setActiveRuleId(rule.id);
|
||
};
|
||
const removeRule = (ruleId: string) => {
|
||
if (props.form.actionRules.length <= 1) return;
|
||
const removedIndex = props.form.actionRules.findIndex((rule) => rule.id === ruleId);
|
||
const nextRules = props.form.actionRules.filter((rule) => rule.id !== ruleId);
|
||
patch({ actionRules: nextRules });
|
||
if (activeRuleId === ruleId) {
|
||
setActiveRuleId(nextRules[Math.max(0, removedIndex - 1)]?.id ?? nextRules[0]?.id ?? '');
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!props.form.actionRules.length) {
|
||
setActiveRuleId('');
|
||
return;
|
||
}
|
||
if (!props.form.actionRules.some((rule) => rule.id === activeRuleId)) {
|
||
setActiveRuleId(props.form.actionRules[0].id);
|
||
}
|
||
}, [activeRuleId, props.form.actionRules]);
|
||
|
||
const formId = 'runner-global-policy-form';
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="runnerPolicyHeader">
|
||
<div className="runnerPolicyHeaderText">
|
||
<CardTitle>{props.form.name}</CardTitle>
|
||
<p className="mutedText">{props.form.description}</p>
|
||
</div>
|
||
<div className="runnerPolicyHeaderActions">
|
||
<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>
|
||
<Button form={formId} type="submit" disabled={props.loading}>
|
||
<Save size={15} />
|
||
保存全局调度策略
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<form id={formId} className="runtimePolicyFormBody runnerPolicyForm" onSubmit={props.onSubmit}>
|
||
<section className="runtimePolicySection runnerPolicySection spanTwo">
|
||
<header>
|
||
<div className="runnerPolicySectionTitle">
|
||
<Route size={15} />
|
||
<span>
|
||
<strong>平台间故障切换</strong>
|
||
<small>按规则决定继续、停止、冷却、禁用或降级后重试</small>
|
||
</span>
|
||
</div>
|
||
</header>
|
||
<div className="runtimePolicyRows runnerPolicyDetailRows">
|
||
<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>
|
||
<div className="runnerToggleField">
|
||
<Toggle
|
||
checked={props.form.singleSourceProtectionEnabled}
|
||
label="启用单一源保护"
|
||
onChange={(checked) => patch({ singleSourceProtectionEnabled: checked })}
|
||
/>
|
||
<span className="runtimeFieldHint">开启后,禁用或冷却当前平台会导致只剩 1 个候选源时,仅继续重试,不自动修改状态。</span>
|
||
</div>
|
||
<div className="spanTwo runnerRuleWorkbench">
|
||
<aside className="runnerRuleList">
|
||
<div className="runnerRuleListHead">
|
||
<strong>规则列表</strong>
|
||
<Button type="button" variant="outline" size="sm" onClick={addRule}>
|
||
<Plus size={14} />
|
||
新增
|
||
</Button>
|
||
</div>
|
||
{props.form.actionRules.map((rule, index) => (
|
||
<button
|
||
className="runnerRuleTab"
|
||
data-active={activeRule?.id === rule.id}
|
||
data-disabled={!rule.enabled}
|
||
key={rule.id}
|
||
type="button"
|
||
onClick={() => setActiveRuleId(rule.id)}
|
||
>
|
||
<span>
|
||
<strong>#{index + 1} {failoverActionText[rule.action]}</strong>
|
||
<small>{runnerActionRuleConditionText(rule)}</small>
|
||
</span>
|
||
<em>{rule.enabled ? '启用' : '停用'}</em>
|
||
</button>
|
||
))}
|
||
</aside>
|
||
|
||
{activeRule && (
|
||
<article className="runnerRuleEditor">
|
||
<header>
|
||
<div>
|
||
<strong>#{activeRuleIndex + 1} {failoverActionText[activeRule.action]}</strong>
|
||
<span>{runnerActionRuleConditionText(activeRule)}</span>
|
||
</div>
|
||
<div className="runnerRuleTools">
|
||
<Toggle checked={activeRule.enabled} label="启用" onChange={(checked) => patchRule(activeRule.id, { enabled: checked })} />
|
||
<Button type="button" variant="destructive" size="sm" disabled={props.form.actionRules.length <= 1} onClick={() => removeRule(activeRule.id)}>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
<div className="runtimePolicyRows runnerRuleFields">
|
||
<KeywordField label="错误分类" value={activeRule.categories} wide={false} options={failoverCategoryOptions} onChange={(value) => patchRule(activeRule.id, { categories: value })} />
|
||
<KeywordField label="错误码" value={activeRule.codes} wide={false} onChange={(value) => patchRule(activeRule.id, { codes: value })} />
|
||
<KeywordField label="HTTP 状态码" value={activeRule.statusCodes} wide={false} onChange={(value) => patchRule(activeRule.id, { statusCodes: value })} />
|
||
<KeywordField label="错误消息关键词" value={activeRule.keywords} wide={false} onChange={(value) => patchRule(activeRule.id, { keywords: value })} />
|
||
<Label>
|
||
命中后的动作
|
||
<Select value={activeRule.action} onChange={(event) => patchRule(activeRule.id, { action: normalizeRunnerFailoverAction(event.target.value) })}>
|
||
{failoverActionOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||
</Select>
|
||
</Label>
|
||
{actionNeedsTarget(activeRule.action) && (
|
||
<Label>
|
||
作用对象
|
||
<Select value={activeRule.target} onChange={(event) => patchRule(activeRule.id, { target: normalizeRunnerFailoverTarget(event.target.value) })}>
|
||
{failoverTargetOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||
</Select>
|
||
</Label>
|
||
)}
|
||
{activeRule.action === 'cooldown_and_next' && (
|
||
<Label>
|
||
冷却时间(秒)
|
||
<Input value={activeRule.cooldownSeconds} inputMode="numeric" onChange={(event) => patchRule(activeRule.id, { cooldownSeconds: event.target.value })} />
|
||
</Label>
|
||
)}
|
||
{activeRule.action === 'demote_and_next' && (
|
||
<Label>
|
||
降级位数
|
||
<Input value={activeRule.demoteSteps} inputMode="numeric" onChange={(event) => patchRule(activeRule.id, { demoteSteps: event.target.value })} />
|
||
</Label>
|
||
)}
|
||
</div>
|
||
</article>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<Label className="spanTwo">元数据 JSON<Textarea value={props.form.metadataJson} rows={4} onChange={(event) => patch({ metadataJson: event.target.value })} /></Label>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function KeywordField(props: { label: string; value: string[]; options?: Array<{ label: string; value: string }>; wide?: boolean; onChange: (value: string[]) => void }) {
|
||
const known = new Set((props.options ?? []).map((item) => item.value));
|
||
const options = [
|
||
...(props.options ?? []),
|
||
...props.value
|
||
.filter((item) => !known.has(item))
|
||
.map((item) => ({ label: item, value: item })),
|
||
];
|
||
return (
|
||
<Label className={`${props.wide === false ? '' : 'spanTwo'} runtimeTagField`.trim()}>
|
||
{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 runnerPolicyToForm(policy: GatewayRunnerPolicy | null): RunnerPolicyForm {
|
||
const failover = readObject(policy?.failoverPolicy);
|
||
const hardStop = readObject(policy?.hardStopPolicy);
|
||
const priorityDemote = readObject(policy?.priorityDemotePolicy);
|
||
const singleSource = readObject(policy?.singleSourcePolicy);
|
||
const cacheAffinity = Object.keys(readObject(policy?.cacheAffinityPolicy)).length > 0 ? readObject(policy?.cacheAffinityPolicy) : defaultCacheAffinityPolicy();
|
||
return {
|
||
name: policy?.name ?? '默认全局调度策略',
|
||
description: policy?.description ?? '控制多个候选平台之间的故障切换;模型运行策略只可覆盖 failoverPolicy,不能覆盖 hardStopPolicy。',
|
||
maxPlatforms: String(readNumber(failover.maxPlatforms, 99)),
|
||
maxDurationSeconds: String(readNumber(failover.maxDurationSeconds, 600)),
|
||
singleSourceProtectionEnabled: readBool(singleSource.enabled, true),
|
||
actionRules: runnerActionRulesFromPolicies(failover, hardStop, priorityDemote),
|
||
cacheAffinityPolicyJson: JSON.stringify(cacheAffinity, null, 2),
|
||
metadataJson: JSON.stringify(policy?.metadata ?? {}, null, 2),
|
||
status: policy?.status ?? 'active',
|
||
};
|
||
}
|
||
|
||
function runnerFormToPayload(form: RunnerPolicyForm): GatewayRunnerPolicyUpsertRequest {
|
||
const actionRules = normalizeRunnerActionRules(form.actionRules);
|
||
const summary = summarizeRunnerActionRules(actionRules);
|
||
return {
|
||
policyKey: 'default-runner-v1',
|
||
name: form.name.trim() || '默认全局调度策略',
|
||
description: form.description.trim() || undefined,
|
||
failoverPolicy: {
|
||
enabled: true,
|
||
maxPlatforms: positiveInt(form.maxPlatforms, 99),
|
||
maxDurationSeconds: positiveInt(form.maxDurationSeconds, 600),
|
||
legacyFieldsDeprecated: true,
|
||
deprecatedFields: legacyRunnerFailoverFields,
|
||
replacedBy: 'actionRules',
|
||
actionRules,
|
||
allowCategories: summary.allowCategories,
|
||
denyCategories: summary.denyCategories,
|
||
allowCodes: summary.allowCodes,
|
||
denyCodes: summary.denyCodes,
|
||
allowKeywords: summary.allowKeywords,
|
||
denyKeywords: summary.denyKeywords,
|
||
allowStatusCodes: summary.allowStatusCodes,
|
||
denyStatusCodes: summary.denyStatusCodes,
|
||
actions: summary.actions,
|
||
},
|
||
hardStopPolicy: {
|
||
enabled: true,
|
||
deprecated: true,
|
||
replacedBy: 'failoverPolicy.actionRules',
|
||
categories: summary.denyCategories,
|
||
codes: summary.denyCodes,
|
||
statusCodes: summary.denyStatusCodes,
|
||
keywords: summary.denyKeywords,
|
||
},
|
||
priorityDemotePolicy: {
|
||
enabled: false,
|
||
deprecated: true,
|
||
replacedBy: 'failoverPolicy.actionRules',
|
||
categories: summary.demoteCategories,
|
||
codes: summary.demoteCodes,
|
||
statusCodes: summary.demoteStatusCodes,
|
||
keywords: summary.demoteKeywords,
|
||
derivedFromActionRules: true,
|
||
},
|
||
singleSourcePolicy: {
|
||
enabled: form.singleSourceProtectionEnabled,
|
||
},
|
||
cacheAffinityPolicy: parseJson(form.cacheAffinityPolicyJson),
|
||
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 defaultCacheAffinityPolicy(): Record<string, unknown> {
|
||
return {
|
||
enabled: true,
|
||
modelTypes: ['chat', 'text_generate', 'responses'],
|
||
minSamples: 3,
|
||
minInputTokens: 512,
|
||
staleAfterSeconds: 86400,
|
||
maxPriorityBoost: 20,
|
||
emaAlpha: 0.35,
|
||
};
|
||
}
|
||
|
||
function nextRunnerActionRuleId() {
|
||
runnerActionRuleId += 1;
|
||
return `runner-action-rule-${Date.now()}-${runnerActionRuleId}`;
|
||
}
|
||
|
||
function createRunnerActionRule(input: Partial<RunnerActionRule>): RunnerActionRule {
|
||
return {
|
||
id: input.id || nextRunnerActionRuleId(),
|
||
enabled: input.enabled !== false,
|
||
categories: cleanTags(input.categories ?? []),
|
||
codes: cleanTags(input.codes ?? []),
|
||
statusCodes: cleanTags(input.statusCodes ?? []),
|
||
keywords: cleanTags(input.keywords ?? []),
|
||
action: normalizeRunnerFailoverAction(input.action),
|
||
target: normalizeRunnerFailoverTarget(input.target),
|
||
demoteSteps: String(positiveInt(input.demoteSteps ?? '1', 1)),
|
||
cooldownSeconds: String(positiveInt(input.cooldownSeconds ?? '300', 300)),
|
||
};
|
||
}
|
||
|
||
function defaultRunnerActionRules() {
|
||
return [
|
||
createRunnerActionRule({
|
||
action: 'cooldown_and_next',
|
||
target: 'model',
|
||
categories: ['rate_limit'],
|
||
codes: ['rate_limit', 'too_many_requests', 'too_many_request', 'rate_limit_exceeded', 'requests_rate_limited', 'requests_limit_exceeded', 'resource_exhausted', 'throttled', 'quota_exceeded', 'quota_exhausted', 'qps_limit', 'rpm_limit', 'tpm_limit'],
|
||
statusCodes: ['429'],
|
||
keywords: ['rate limit', 'rate_limit', 'rate-limit', 'too many requests', 'too_many_requests', '429', 'throttle', 'throttled', 'resource exhausted', 'quota exceeded', 'quota_exceeded', 'rate exceeded', 'request limit', 'requests per minute', 'tokens per minute', 'qps', 'rpm', 'tpm'],
|
||
cooldownSeconds: '300',
|
||
}),
|
||
createRunnerActionRule({
|
||
action: 'disable_and_next',
|
||
target: 'platform',
|
||
categories: ['auth_error'],
|
||
codes: ['auth_failed', 'invalid_api_key', 'missing_credentials', 'missing_credential', 'unauthorized', 'forbidden', 'account_disabled', 'billing_not_active', 'permission_denied'],
|
||
statusCodes: ['401', '403'],
|
||
keywords: ['invalid api key', 'api key is invalid', 'invalid_api_key', 'apikey invalid', 'api key invalid', 'unauthorized', 'forbidden', 'authentication', 'auth failed', 'credential', 'missing credential', 'permission denied', 'access denied', 'invalid signature', 'signature mismatch', 'secret key', 'access key', 'account disabled', 'account_deactivated', 'billing not active', 'billing_not_active', 'insufficient balance', 'quota exceeded', '余额不足', '欠费'],
|
||
}),
|
||
createRunnerActionRule({
|
||
action: 'demote_and_next',
|
||
target: 'platform',
|
||
categories: ['timeout', 'provider_5xx', 'provider_overloaded'],
|
||
codes: ['timeout', 'server_error', 'overloaded', 'provider_failed', 'invalid_response', 'script_timeout', 'request_asset_fetch_failed', 'upload_failed', 'upload_read_failed', 'upload_source_read_failed'],
|
||
statusCodes: ['408', '500', '502', '503', '504', '520', '521', '522', '523', '524', '529', '530'],
|
||
keywords: ['timeout', 'timed out', 'deadline exceeded', 'context deadline exceeded', 'overloaded', 'overload', 'temporarily_unavailable', 'temporarily unavailable', 'temporary unavailable', 'service unavailable', 'server error', 'internal server error', 'bad gateway', 'gateway timeout', 'upstream timeout', 'upstream error', 'provider failed', 'model is overloaded', 'capacity', 'try again later', 'please try again later', '5xx', '500', '502', '503', '504'],
|
||
demoteSteps: '1',
|
||
}),
|
||
createRunnerActionRule({
|
||
action: 'next',
|
||
categories: ['network', 'stream_error'],
|
||
codes: ['network', 'stream_read_error', 'cancelled', 'request_asset_fetch_failed', 'upload_network', 'upload_config_failed', 'local_static_store_failed', 'upload_source_fetch_failed'],
|
||
statusCodes: [],
|
||
keywords: ['network', 'socket hang up', 'socket closed', 'connection reset', 'connection refused', 'connection aborted', 'broken pipe', 'econnreset', 'econnrefused', 'eai_again', 'enotfound', 'no such host', 'dns', 'tls handshake timeout', 'unexpected eof', 'connection closed', 'stream error', 'stream_read_error', 'read failed', 'fetch failed'],
|
||
}),
|
||
createRunnerActionRule({
|
||
action: 'stop',
|
||
categories: ['request_error', 'unsupported_model', 'user_permission', 'insufficient_balance'],
|
||
codes: ['bad_request', 'invalid_request', 'invalid_parameter', 'missing_required', 'unsupported_kind', 'unsupported_model', 'unsupported_operation', 'unsupported_request_resolution', 'request_asset_input_format_unsupported', 'request_asset_expired', 'request_asset_decode_failed', 'request_asset_public_url_required', 'upload_no_channel', 'upload_unsupported_channel', 'upload_source_too_large', 'upload_source_invalid_url', 'upload_source_unsupported_url', 'upload_decode_failed', 'invalid_proxy', 'invalid_json_body', 'invalid_multipart_body', 'invalid_multipart_file', 'invalid_multipart_image', 'invalid_multipart_audio', 'missing_configuration', 'script_error', 'cloned_voice_not_found', 'cloned_voice_expired', 'cloned_voice_unavailable', 'insufficient_balance', 'permission_denied'],
|
||
statusCodes: ['400', '404', '405', '406', '409', '410', '411', '413', '415', '422'],
|
||
keywords: ['invalid_parameter', 'invalid parameter', 'missing required', 'missing_required', 'bad request', 'unsupported', 'not supported', 'does not support', 'is not supported', 'insufficient balance', 'permission denied', 'no permission', 'invalid json', 'invalid multipart', 'image is required', 'audio must', 'prompt is required', 'model is required', 'exceeds', 'too large', 'expired', 'not found', 'required'],
|
||
}),
|
||
];
|
||
}
|
||
|
||
function runnerActionRulesFromPolicies(failover: Record<string, unknown>, hardStop: Record<string, unknown>, priorityDemote: Record<string, unknown>) {
|
||
const storedRules = Array.isArray(failover.actionRules) ? failover.actionRules : [];
|
||
const hydrated = storedRules
|
||
.map((item) => runnerActionRuleFromRecord(readObject(item)))
|
||
.filter((item): item is RunnerActionRule => Boolean(item));
|
||
if (hydrated.length) {
|
||
return isLegacyDefaultRunnerActionRules(hydrated) ? defaultRunnerActionRules() : hydrated;
|
||
}
|
||
|
||
const migrated = [
|
||
...legacyFailoverActionRules(failover),
|
||
createRunnerActionRule({
|
||
action: 'demote_and_next',
|
||
target: 'platform',
|
||
categories: tagsFromValue(priorityDemote.categories),
|
||
codes: tagsFromValue(priorityDemote.codes),
|
||
statusCodes: tagsFromValue(priorityDemote.statusCodes),
|
||
keywords: tagsFromValue(priorityDemote.keywords),
|
||
demoteSteps: String(readNumber(priorityDemote.demoteSteps, 1)),
|
||
}),
|
||
createRunnerActionRule({
|
||
action: 'stop',
|
||
categories: tagsFromValue(hardStop.categories),
|
||
codes: tagsFromValue(hardStop.codes),
|
||
statusCodes: tagsFromValue(hardStop.statusCodes),
|
||
keywords: tagsFromValue(hardStop.keywords),
|
||
}),
|
||
].filter(hasRunnerActionRuleCondition);
|
||
return migrated.length ? migrated : defaultRunnerActionRules();
|
||
}
|
||
|
||
function isLegacyDefaultRunnerActionRules(rules: RunnerActionRule[]) {
|
||
if (rules.length !== 5) return false;
|
||
const byAction = new Map<RunnerFailoverAction, RunnerActionRule>();
|
||
for (const rule of rules) {
|
||
if (byAction.has(rule.action)) return false;
|
||
byAction.set(rule.action, rule);
|
||
}
|
||
const next = byAction.get('next');
|
||
const cooldown = byAction.get('cooldown_and_next');
|
||
const demote = byAction.get('demote_and_next');
|
||
const disable = byAction.get('disable_and_next');
|
||
const stop = byAction.get('stop');
|
||
if (!next || !cooldown || !demote || !disable || !stop) return false;
|
||
if (hasEnhancedRunnerDefaultTerms(rules)) return false;
|
||
|
||
return (
|
||
ruleHasAll(cooldown, 'categories', ['rate_limit']) &&
|
||
ruleHasAll(cooldown, 'codes', ['rate_limit']) &&
|
||
ruleHasAll(cooldown, 'statusCodes', ['429']) &&
|
||
cooldown.codes.length <= 1 &&
|
||
cooldown.keywords.length <= 3 &&
|
||
ruleHasAll(disable, 'categories', ['auth_error']) &&
|
||
ruleHasAll(disable, 'codes', ['auth_failed', 'invalid_api_key', 'missing_credentials']) &&
|
||
ruleHasAll(disable, 'statusCodes', ['401', '403']) &&
|
||
disable.codes.length <= 3 &&
|
||
disable.keywords.length <= 8 &&
|
||
ruleHasAll(stop, 'categories', ['request_error', 'unsupported_model', 'user_permission', 'insufficient_balance']) &&
|
||
stop.codes.length <= 8 &&
|
||
stop.keywords.length <= 4 &&
|
||
next.categories.length >= 5 &&
|
||
next.codes.length <= 6 &&
|
||
next.keywords.length <= 13 &&
|
||
(ruleHasAll(demote, 'keywords', ['temporarily_unavailable']) || ruleHasAll(demote, 'categories', ['provider_5xx']) || ruleHasAll(demote, 'codes', ['server_error'])) &&
|
||
demote.codes.length <= 6 &&
|
||
demote.keywords.length <= 8
|
||
);
|
||
}
|
||
|
||
function hasEnhancedRunnerDefaultTerms(rules: RunnerActionRule[]) {
|
||
const markers = new Set([
|
||
'resource_exhausted',
|
||
'rpm_limit',
|
||
'tpm_limit',
|
||
'account_disabled',
|
||
'billing_not_active',
|
||
'provider_failed',
|
||
'invalid_response',
|
||
'request_asset_fetch_failed',
|
||
'socket hang up',
|
||
'econnreset',
|
||
'request_asset_input_format_unsupported',
|
||
'invalid_multipart_image',
|
||
]);
|
||
for (const rule of rules) {
|
||
for (const value of [...rule.categories, ...rule.codes, ...rule.statusCodes, ...rule.keywords]) {
|
||
if (markers.has(value.trim().toLowerCase())) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function ruleHasAll(rule: RunnerActionRule, key: 'categories' | 'codes' | 'statusCodes' | 'keywords', values: string[]) {
|
||
const actual = new Set(rule[key].map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||
return values.every((value) => actual.has(value.toLowerCase()));
|
||
}
|
||
|
||
function legacyFailoverActionRules(failover: Record<string, unknown>) {
|
||
const actions = readObject(failover.actions);
|
||
const grouped = new Map<RunnerFailoverAction, string[]>();
|
||
for (const category of tagsFromValue(failover.allowCategories)) {
|
||
const action = normalizeRunnerFailoverAction(typeof actions[category] === 'string' ? actions[category] as string : 'next');
|
||
if (action === 'stop') continue;
|
||
grouped.set(action, [...(grouped.get(action) ?? []), category]);
|
||
}
|
||
const rules: RunnerActionRule[] = [];
|
||
for (const [action, categories] of grouped.entries()) {
|
||
rules.push(createRunnerActionRule({ action, target: defaultRunnerActionTarget(action), categories }));
|
||
}
|
||
const allowCodes = tagsFromValue(failover.allowCodes);
|
||
const allowStatusCodes = tagsFromValue(failover.allowStatusCodes);
|
||
const allowKeywords = tagsFromValue(failover.allowKeywords);
|
||
if (allowCodes.length || allowStatusCodes.length || allowKeywords.length) {
|
||
rules.push(createRunnerActionRule({
|
||
action: 'next',
|
||
codes: allowCodes,
|
||
statusCodes: allowStatusCodes,
|
||
keywords: allowKeywords,
|
||
}));
|
||
}
|
||
const denyCategories = tagsFromValue(failover.denyCategories);
|
||
const denyCodes = tagsFromValue(failover.denyCodes);
|
||
const denyStatusCodes = tagsFromValue(failover.denyStatusCodes);
|
||
const denyKeywords = tagsFromValue(failover.denyKeywords);
|
||
if (denyCategories.length || denyCodes.length || denyStatusCodes.length || denyKeywords.length) {
|
||
rules.push(createRunnerActionRule({
|
||
action: 'stop',
|
||
categories: denyCategories,
|
||
codes: denyCodes,
|
||
statusCodes: denyStatusCodes,
|
||
keywords: denyKeywords,
|
||
}));
|
||
}
|
||
return rules;
|
||
}
|
||
|
||
function runnerActionRuleFromRecord(rule: Record<string, unknown>) {
|
||
const result = createRunnerActionRule({
|
||
enabled: readBool(rule.enabled, true),
|
||
categories: tagsFromValue(rule.categories),
|
||
codes: tagsFromValue(rule.codes ?? rule.errorCodes),
|
||
statusCodes: tagsFromValue(rule.statusCodes),
|
||
keywords: tagsFromValue(rule.keywords),
|
||
action: normalizeRunnerFailoverAction(rule.action),
|
||
target: normalizeRunnerFailoverTarget(rule.target),
|
||
demoteSteps: String(readNumber(rule.demoteSteps, 1)),
|
||
cooldownSeconds: String(readNumber(rule.cooldownSeconds, 300)),
|
||
});
|
||
return hasRunnerActionRuleCondition(result) ? result : null;
|
||
}
|
||
|
||
function normalizeRunnerActionRules(rules: RunnerActionRule[]) {
|
||
return rules
|
||
.map((rule) => createRunnerActionRule(rule))
|
||
.filter(hasRunnerActionRuleCondition)
|
||
.map((rule) => ({
|
||
enabled: rule.enabled,
|
||
categories: cleanTags(rule.categories),
|
||
codes: cleanTags(rule.codes),
|
||
statusCodes: parseNumberTags(rule.statusCodes),
|
||
keywords: cleanTags(rule.keywords),
|
||
action: rule.action,
|
||
target: actionNeedsTarget(rule.action) ? rule.target : undefined,
|
||
demoteSteps: rule.action === 'demote_and_next' ? positiveInt(rule.demoteSteps, 1) : undefined,
|
||
cooldownSeconds: rule.action === 'cooldown_and_next' ? positiveInt(rule.cooldownSeconds, 300) : undefined,
|
||
}));
|
||
}
|
||
|
||
function summarizeRunnerActionRules(rules: Array<Record<string, unknown>>) {
|
||
const byAction = (action: RunnerFailoverAction) => rules.filter((rule) => rule.action === action);
|
||
const retryRules = rules.filter((rule) => rule.action !== 'stop');
|
||
const stopRules = byAction('stop');
|
||
const demoteRules = byAction('demote_and_next');
|
||
const collectStrings = (items: Array<Record<string, unknown>>, field: string) => cleanTags(items.flatMap((rule) => Array.isArray(rule[field]) ? rule[field].map(String) : []));
|
||
const collectStatusCodes = (items: Array<Record<string, unknown>>, field: string) => parseNumberTags(collectStrings(items, field));
|
||
return {
|
||
allowCategories: collectStrings(retryRules, 'categories'),
|
||
denyCategories: collectStrings(stopRules, 'categories'),
|
||
allowCodes: collectStrings(retryRules, 'codes'),
|
||
denyCodes: collectStrings(stopRules, 'codes'),
|
||
allowKeywords: collectStrings(retryRules, 'keywords'),
|
||
denyKeywords: collectStrings(stopRules, 'keywords'),
|
||
allowStatusCodes: collectStatusCodes(retryRules, 'statusCodes'),
|
||
denyStatusCodes: collectStatusCodes(stopRules, 'statusCodes'),
|
||
demoteCategories: collectStrings(demoteRules, 'categories'),
|
||
demoteCodes: collectStrings(demoteRules, 'codes'),
|
||
demoteKeywords: collectStrings(demoteRules, 'keywords'),
|
||
demoteStatusCodes: collectStatusCodes(demoteRules, 'statusCodes'),
|
||
actions: Object.fromEntries(retryRules
|
||
.flatMap((rule) => (Array.isArray(rule.categories) ? rule.categories : []).map((category) => [String(category), rule.action]))
|
||
.filter(([, action]) => action && action !== 'next')),
|
||
};
|
||
}
|
||
|
||
function hasRunnerActionRuleCondition(rule: RunnerActionRule) {
|
||
return Boolean(rule.categories.length || rule.codes.length || rule.statusCodes.length || rule.keywords.length);
|
||
}
|
||
|
||
function runnerActionRuleConditionText(rule: RunnerActionRule) {
|
||
const parts = [
|
||
rule.categories.length ? `分类 ${rule.categories.length}` : '',
|
||
rule.codes.length ? `错误码 ${rule.codes.length}` : '',
|
||
rule.statusCodes.length ? `状态码 ${rule.statusCodes.length}` : '',
|
||
rule.keywords.length ? `关键词 ${rule.keywords.length}` : '',
|
||
].filter(Boolean);
|
||
return parts.join(' · ') || '未配置匹配条件';
|
||
}
|
||
|
||
function normalizeRunnerFailoverAction(value: unknown): RunnerFailoverAction {
|
||
switch (String(value || '').trim()) {
|
||
case 'rotate':
|
||
case 'next':
|
||
return 'next';
|
||
case 'cooldown_and_rotate':
|
||
case 'cooldown_and_next':
|
||
return 'cooldown_and_next';
|
||
case 'demote_and_rotate':
|
||
case 'demote_and_next':
|
||
return 'demote_and_next';
|
||
case 'disable_and_rotate':
|
||
case 'disable_and_next':
|
||
return 'disable_and_next';
|
||
case 'stop':
|
||
return 'stop';
|
||
default:
|
||
return 'next';
|
||
}
|
||
}
|
||
|
||
function normalizeRunnerFailoverTarget(value: unknown): RunnerFailoverTarget {
|
||
const target = String(value || '').trim();
|
||
return target === 'model' ? 'model' : 'platform';
|
||
}
|
||
|
||
function actionNeedsTarget(action: RunnerFailoverAction) {
|
||
return action === 'cooldown_and_next' || action === 'disable_and_next';
|
||
}
|
||
|
||
function defaultRunnerActionTarget(action: RunnerFailoverAction): RunnerFailoverTarget {
|
||
return action === 'cooldown_and_next' ? 'model' : 'platform';
|
||
}
|
||
|
||
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: formRateLimitText(readRateLimit(rateRules, 'rpm')),
|
||
tpm: formRateLimitText(readRateLimit(rateRules, 'tpm_total')),
|
||
concurrency: formRateLimitText(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 = rateLimitText(readRateLimit(rules, 'rpm'));
|
||
const tpm = rateLimitText(readRateLimit(rules, 'tpm_total'));
|
||
const concurrent = rateLimitText(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);
|
||
const limit = Number(readObject(rule).limit);
|
||
return Number.isFinite(limit) ? limit : undefined;
|
||
}
|
||
|
||
function formRateLimitText(value: number | undefined) {
|
||
return value === undefined ? '' : String(value);
|
||
}
|
||
|
||
function rateLimitText(value: number | undefined) {
|
||
return value !== undefined && value > 0 ? String(value) : '不限';
|
||
}
|
||
|
||
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>;
|
||
}
|