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

1104 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { useEffect, useState, type FormEvent } from 'react';
import { Select as AntSelect } from 'antd';
import { Gauge, Pencil, Plus, RotateCcw, Route, Save, ShieldCheck, Trash2 } from 'lucide-react';
import type { GatewayRunnerPolicy, GatewayRunnerPolicyUpsertRequest, RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type RuntimePanelTab = 'model' | 'runner';
type 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>;
}