Files
easyai-ai-gateway/apps/web/src/pages/admin/RuntimePoliciesPanel.tsx
T
wangbo 9e4fc7362d feat(queue): 增加非文本模型分布式准入队列
使用 PostgreSQL 统一同步与异步非文本任务的并发准入、持久化等待和 Worker 容量分配,并将生产 API 与独立 Worker 角色拆分。

补充策略管理、共享契约、OpenAPI、Kubernetes 双节点 Worker 清单及跨节点验收脚本;未默认启用任何生产 queue_size 策略。

已在原基线完成 Go、前端、迁移、Shell、Kustomize 与长任务容量验收;合入最新主干后将重新执行发布门禁。
2026-07-29 16:15:43 +08:00

1191 lines
53 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,
RunnerFailoverAction,
RunnerFailoverTarget,
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 RunnerActionRule = {
id: string;
enabled: boolean;
categories: string[];
codes: string[];
statusCodes: string[];
keywords: string[];
action: RunnerFailoverAction;
target: RunnerFailoverTarget;
demoteSteps: string;
cooldownSeconds: string;
};
export type RuntimePolicyForm = {
policyKey: string;
name: string;
description: string;
rpm: string;
tpm: string;
concurrency: string;
queueSize: string;
queueMaxWaitSeconds: string;
rateLimitPolicyExtra: Record<string, unknown>;
rateLimitRuleExtras: Record<string, Record<string, unknown>>;
rateLimitPreservedRules: Array<Record<string, unknown>>;
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 {
if (runnerForm.actionRules.some((rule) => !hasRunnerActionRuleCondition(rule))) {
throw new Error('每条故障动作规则至少需要一个匹配条件');
}
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">模型运行策略维护限流和平台内重试;旧自动禁用、冷却字段仅兼容读取,全局 action rule 统一控制平台间动作。</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>
<Label>排队长度<Input value={form.queueSize} placeholder="0 或留空关闭排队" inputMode="numeric" onChange={(event) => setForm({ ...form, queueSize: event.target.value })} /></Label>
<Label>最长等待(秒)<Input value={form.queueMaxWaitSeconds} placeholder="默认 600" inputMode="numeric" disabled={!Number(form.queueSize)} onChange={(event) => setForm({ ...form, queueMaxWaitSeconds: 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>仅用于读取旧配置;新配置请在 Runner action rule 编辑器中完成</span></header>
<div className="runtimePolicyRows">
<Toggle checked={form.autoDisableEnabled} disabled label="旧自动禁用" onChange={() => undefined} />
<Label>禁用触发次数<Input disabled value={form.autoDisableThreshold} inputMode="numeric" /></Label>
<KeywordField disabled label="旧自动禁用关键词" value={form.autoDisableKeywords} onChange={() => undefined} />
<Toggle checked={form.degradeEnabled} disabled label="旧模型冷却" onChange={() => undefined} />
<Label>模型冷却秒数<Input disabled value={form.degradeCooldownSeconds} inputMode="numeric" /></Label>
<KeywordField disabled label="旧模型冷却关键词" value={form.degradeKeywords} onChange={() => undefined} />
</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; disabled?: boolean; label: string; onChange: (checked: boolean) => void }) {
return (
<label className="platformToggle">
<input type="checkbox" checked={props.checked} disabled={props.disabled} 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">开启后,数据库事务会按实时可用来源判断;没有其他来源时跳过冷却或禁用。</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: { disabled?: boolean; 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"
disabled={props.disabled}
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',
queueSize: '',
queueMaxWaitSeconds: '600',
rateLimitPolicyExtra: {},
rateLimitRuleExtras: {},
rateLimitPreservedRules: [],
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: '1',
autoDisableKeywords: [],
degradeEnabled: false,
degradeCooldownSeconds: '300',
degradeKeywords: [],
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';
}
export function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
const rateRules = Array.isArray(policy.rateLimitPolicy?.rules) ? policy.rateLimitPolicy.rules : [];
const rateLimitParts = splitRuntimeRateLimitPolicy(readObject(policy.rateLimitPolicy));
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')),
queueSize: formRateLimitText(readRateLimit(rateRules, 'queue_size')),
queueMaxWaitSeconds: formRateLimitPropertyText(readRateLimitRule(rateRules, 'queue_size'), 'maxWaitSeconds', '600'),
rateLimitPolicyExtra: rateLimitParts.extra,
rateLimitRuleExtras: rateLimitParts.ruleExtras,
rateLimitPreservedRules: rateLimitParts.preservedRules,
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',
};
}
export function formToPayload(form: RuntimePolicyForm): RuntimePolicySetUpsertRequest {
return {
policyKey: form.policyKey.trim(),
name: form.name.trim(),
description: form.description.trim() || undefined,
rateLimitPolicy: {
...form.rateLimitPolicyExtra,
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 [
...form.rateLimitPreservedRules,
limitRule('rpm', form.rpm, 60, undefined, form.rateLimitRuleExtras.rpm),
limitRule('tpm_total', form.tpm, 60, undefined, form.rateLimitRuleExtras.tpm_total),
limitRule('concurrent', form.concurrency, 60, 120, form.rateLimitRuleExtras.concurrent),
queueLimitRule(form.queueSize, form.queueMaxWaitSeconds, form.rateLimitRuleExtras.queue_size),
].filter((rule): rule is NonNullable<ReturnType<typeof limitRule>> => Boolean(rule));
}
function queueLimitRule(queueSize: string, maxWaitSeconds: string, extra: Record<string, unknown> = {}) {
if (!queueSize.trim() || Number(queueSize) === 0) return undefined;
const limit = boundedPositiveInt(queueSize, '排队长度', 10000);
const maxWait = maxWaitSeconds.trim() ? boundedPositiveInt(maxWaitSeconds, '最长等待', 3600) : 600;
return { ...extra, metric: 'queue_size', limit, maxWaitSeconds: maxWait };
}
function limitRule(metric: string, value: string, windowSeconds = 60, leaseTtlSeconds?: number, extra: Record<string, unknown> = {}) {
const limit = Number(value);
if (!Number.isFinite(limit) || limit <= 0) return undefined;
return {
...extra,
metric,
limit,
windowSeconds: typeof extra.windowSeconds === 'number' ? extra.windowSeconds : windowSeconds,
leaseTtlSeconds: typeof extra.leaseTtlSeconds === 'number' ? extra.leaseTtlSeconds : leaseTtlSeconds,
};
}
function splitRuntimeRateLimitPolicy(policy: Record<string, unknown>) {
const editableMetrics = new Set(['rpm', 'tpm_total', 'concurrent', 'queue_size']);
const extra = { ...policy };
delete extra.rules;
const ruleExtras: Record<string, Record<string, unknown>> = {};
const preservedRules: Array<Record<string, unknown>> = [];
const rules = Array.isArray(policy.rules) ? policy.rules : [];
rules.forEach((rawRule) => {
const rule = readObject(rawRule);
const metric = typeof rule.metric === 'string' ? rule.metric.trim() : '';
if (!editableMetrics.has(metric)) {
preservedRules.push({ ...rule });
return;
}
const ruleExtra = { ...rule };
delete ruleExtra.metric;
delete ruleExtra.limit;
delete ruleExtra.maxWaitSeconds;
ruleExtras[metric] = ruleExtra;
});
return { extra, ruleExtras, preservedRules };
}
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'));
const queue = readRateLimit(rules, 'queue_size');
const queueText = queue ? ` / 排队 ${rateLimitText(queue)} / 最长 ${formRateLimitPropertyText(readRateLimitRule(rules, 'queue_size'), 'maxWaitSeconds', '600')}秒` : '';
return `RPM ${rpm} / TPM ${tpm} / 并发 ${concurrent}${queueText}`;
}
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 = readRateLimitRule(rules, metric);
const limit = Number(readObject(rule).limit);
return Number.isFinite(limit) ? limit : undefined;
}
function readRateLimitRule(rules: unknown[], metric: string) {
return rules.find((item) => readObject(item).metric === metric);
}
function formRateLimitPropertyText(rule: unknown, key: string, fallback = '') {
const value = Number(readObject(rule)[key]);
return Number.isFinite(value) && value > 0 ? String(value) : fallback;
}
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 boundedPositiveInt(value: string, label: string, max: number) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {
throw new Error(`${label}必须是 1${max} 的整数`);
}
return parsed;
}
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>;
}