Decouple stream cancellation and clarify failover rules

This commit is contained in:
2026-05-12 21:32:23 +08:00
parent 98abd247d6
commit 682a491d27
8 changed files with 629 additions and 17 deletions
+80 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Popover as AntPopover } from 'antd';
import { CheckCircle2, Gauge, History, RotateCcw, SlidersHorizontal } from 'lucide-react';
import type { IntegrationPlatform, ModelRateLimitStatus, PlatformDynamicPriorityUpdateRequest, PriorityDemotionRecord } from '@easyai-ai-gateway/contracts';
import type { IntegrationPlatform, ModelRateLimitStatus, PlatformDynamicPriorityUpdateRequest, PlatformPolicyEvent, PriorityDemotionRecord } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow } from '../../components/ui';
export function RealtimeLoadPanel(props: {
@@ -124,6 +124,7 @@ function RateLimitStatusTable(props: {
<TableRow className="shTableHeader">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="platformLimitStatusHead"></TableHead>
<TableHead className="platformLimitNumberHead"></TableHead>
<TableHead className="platformLimitNumberHead"></TableHead>
<TableHead className="platformLimitMetricHead platformLimitNumberHead" title="正在执行 / 并发上限 / 排队任务">
@@ -132,7 +133,6 @@ function RateLimitStatusTable(props: {
</TableHead>
<TableHead className="platformLimitNumberHead">TPM</TableHead>
<TableHead className="platformLimitNumberHead">RPM</TableHead>
<TableHead className="platformLimitStatusHead"></TableHead>
</TableRow>
{props.statuses.map((status) => {
const platform = props.platformMap.get(status.platformId);
@@ -150,6 +150,7 @@ function RateLimitStatusTable(props: {
<small>{status.provider}</small>
</span>
</TableCell>
<TableCell className="platformLimitStatusCell">{modelRuntimeStatusCell(status, platform, props.now)}</TableCell>
<TableCell className="platformLimitNumberCell">{platformPriorityCell(status, platform, props.onAdjustPriority)}</TableCell>
<TableCell className="platformLimitNumberCell">
<span className="rateLoadCell" data-overloaded={status.loadRatio > 0.8 ? 'true' : undefined}>
@@ -160,7 +161,6 @@ function RateLimitStatusTable(props: {
<TableCell className="platformLimitNumberCell">{concurrencyMetricCell(status)}</TableCell>
<TableCell className="platformLimitNumberCell">{metricCell(status.tpm, true)}</TableCell>
<TableCell className="platformLimitNumberCell">{metricCell(status.rpm)}</TableCell>
<TableCell className="platformLimitStatusCell">{modelRuntimeStatusCell(status, props.now)}</TableCell>
</TableRow>
);
})}
@@ -300,6 +300,32 @@ function PriorityDemotionPopover(props: { records: PriorityDemotionRecord[] }) {
);
}
function PlatformDisabledReasonPopover(props: { record?: PlatformPolicyEvent }) {
const record = props.record;
if (!record) {
return (
<span className="priorityDemotionPopover" role="tooltip">
<span className="priorityDemotionEmpty"></span>
</span>
);
}
return (
<span className="priorityDemotionPopover" role="tooltip">
<span className="priorityDemotionHeader">
<History size={14} />
<strong></strong>
</span>
<span className="priorityDemotionItem">
<span className="priorityDemotionItemHeader">
<strong>{platformPolicyEventReasonText(record)}</strong>
</span>
<small>{platformPolicyEventMetaText(record)}</small>
{record.errorMessage && <span className="priorityDemotionError">{record.errorMessage}</span>}
</span>
</span>
);
}
function platformPrioritySubtitle(status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined, demotionCount: number) {
const staticPriority = platformStaticPriority(status, platform);
const dynamicPriority = platformDynamicPriority(status, platform);
@@ -338,12 +364,45 @@ function priorityDemotionPolicyText(record: PriorityDemotionRecord) {
function priorityDemotionReasonLabel(reason: string | undefined) {
const labels: Record<string, string> = {
priority_demote_policy: '命中优先级降级规则',
failover_allow_policy: '命中跨平台切换规则',
auto_disable_policy: '命中自动禁用规则',
client_retryable: '客户端错误可重试',
hard_stop_policy: '命中硬拒绝规则',
runner_policy_disabled: '全局调度策略停用',
};
return reason ? labels[reason] ?? reason : '优先级降级';
}
function platformPolicyEventReasonText(record: PlatformPolicyEvent) {
const category = record.category ? `错误分类 ${record.category}` : '';
const code = record.errorCode ? `错误 ${record.errorCode}` : '';
const statusCode = record.statusCode ? `状态码 ${record.statusCode}` : '';
return [platformPolicyEventReasonLabel(record), statusCode, code, category].filter(Boolean).join(' · ');
}
function platformPolicyEventMetaText(record: PlatformPolicyEvent) {
const policyPath = [record.policySource || record.policy, record.policyRule].filter(Boolean).join('.');
const policy = policyPath ? (record.matchedValue ? `策略 ${policyPath}=${record.matchedValue}` : `策略 ${policyPath}`) : '';
return [
formatDateTime(record.createdAt),
shortId(record.taskId) ? `任务 ${shortId(record.taskId)}` : '',
policy,
].filter(Boolean).join(' · ') || '-';
}
function platformPolicyEventReasonLabel(record: PlatformPolicyEvent) {
if (record.reason) {
return priorityDemotionReasonLabel(record.reason);
}
if (record.eventType === 'task.policy.auto_disabled') {
return '命中自动禁用规则';
}
if (record.eventType === 'task.policy.failover_disabled') {
return '跨平台切换时禁用';
}
return '平台已禁用';
}
function metricCell(metric: ModelRateLimitStatus['rpm'], includeReserved = false) {
if (!metric.limited) return <span className="rateMetricCell"><strong>{formatLimit(metric.currentValue)} / </strong><small>{includeReserved ? reservedMetricText(metric) : '未配置上限'}</small></span>;
return (
@@ -388,9 +447,10 @@ function shortId(value: string | undefined) {
return value.length > 8 ? value.slice(0, 8) : value;
}
function modelRuntimeStatusCell(status: ModelRateLimitStatus, now: number) {
function modelRuntimeStatusCell(status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined, now: number) {
const modelCooldownMs = cooldownRemainingMs(status.modelCooldownUntil, now);
const platformCooldownMs = cooldownRemainingMs(status.platformCooldownUntil, now);
const platformStatus = platform?.status || status.platformStatus || 'enabled';
if (modelCooldownMs > 0) {
return (
<span className="platformTableName">
@@ -399,6 +459,22 @@ function modelRuntimeStatusCell(status: ModelRateLimitStatus, now: number) {
</span>
);
}
if (platformStatus !== 'enabled') {
const badge = <Badge variant="warning"></Badge>;
return (
<AntPopover
align={{ offset: [0, 8] }}
content={<PlatformDisabledReasonPopover record={status.platformDisabledReason} />}
overlayClassName="priorityDemotionAntPopover"
placement="bottomLeft"
trigger={['hover', 'focus']}
>
<span className="platformTableName" tabIndex={0}>
<strong>{badge}</strong>
</span>
</AntPopover>
);
}
if (platformCooldownMs > 0) {
return (
<span className="platformTableName">
@@ -58,6 +58,39 @@ type RunnerPolicyForm = {
status: string;
};
const failoverActionDefinitions = [
{
value: 'next',
title: '仅轮转',
description: '这些错误分类只尝试下一个平台,不修改当前平台状态。',
},
{
value: 'disable_and_next',
title: '禁用后轮转',
description: '这些错误分类会先禁用当前平台,再尝试下一个平台。',
},
{
value: 'cooldown_and_next',
title: '冷却后轮转',
description: '这些错误分类会先让当前平台模型进入冷却,再尝试下一个平台。',
},
] as const;
const failoverCategoryOptions = [
'network',
'timeout',
'stream_error',
'rate_limit',
'provider_5xx',
'provider_overloaded',
'auth_error',
'request_error',
'unsupported_model',
'user_permission',
'insufficient_balance',
'client_error',
].map((item) => ({ label: item, value: item }));
export function RuntimePoliciesPanel(props: {
message: string;
runnerPolicy: GatewayRunnerPolicy | null;
@@ -380,14 +413,22 @@ function RunnerPolicyEditor(props: {
<Input value={props.form.maxDurationSeconds} inputMode="numeric" onChange={(event) => patch({ maxDurationSeconds: event.target.value })} />
<span className="runtimeFieldHint"> 600 </span>
</Label>
<KeywordField label="允许分类" value={props.form.allowCategories} onChange={(value) => patch({ allowCategories: value })} />
<KeywordField label="拒绝分类" value={props.form.denyCategories} onChange={(value) => patch({ denyCategories: value })} />
<KeywordField label="允许错误码" value={props.form.allowCodes} onChange={(value) => patch({ allowCodes: value })} />
<KeywordField label="拒绝错误码" value={props.form.denyCodes} onChange={(value) => patch({ denyCodes: value })} />
<KeywordField label="允许关键词" value={props.form.allowKeywords} onChange={(value) => patch({ allowKeywords: value })} />
<KeywordField label="拒绝关键词" value={props.form.denyKeywords} onChange={(value) => patch({ denyKeywords: value })} />
<KeywordField label="允许状态码" value={props.form.allowStatusCodes} onChange={(value) => patch({ allowStatusCodes: value })} />
<KeywordField label="拒绝状态码" value={props.form.denyStatusCodes} onChange={(value) => patch({ denyStatusCodes: value })} />
<FailoverCategoryRoutingEditor
actions={props.form.failoverActions}
allowCategories={props.form.allowCategories}
denyCategories={props.form.denyCategories}
onChange={(value) => patch(value)}
/>
<div className="spanTwo runnerSupplementalRules">
<strong></strong>
<small></small>
</div>
<KeywordField label="补充触发错误码" value={props.form.allowCodes} onChange={(value) => patch({ allowCodes: value })} />
<KeywordField label="排除触发错误码" value={props.form.denyCodes} onChange={(value) => patch({ denyCodes: value })} />
<KeywordField label="补充触发关键词" value={props.form.allowKeywords} onChange={(value) => patch({ allowKeywords: value })} />
<KeywordField label="排除触发关键词" value={props.form.denyKeywords} onChange={(value) => patch({ denyKeywords: value })} />
<KeywordField label="补充触发状态码" value={props.form.allowStatusCodes} onChange={(value) => patch({ allowStatusCodes: value })} />
<KeywordField label="排除触发状态码" value={props.form.denyStatusCodes} onChange={(value) => patch({ denyStatusCodes: value })} />
</div>
)}
@@ -448,6 +489,65 @@ function KeywordField(props: { label: string; value: string[]; onChange: (value:
);
}
function FailoverCategoryRoutingEditor(props: {
actions: Record<string, unknown>;
allowCategories: string[];
denyCategories: string[];
onChange: (value: Pick<RunnerPolicyForm, 'allowCategories' | 'denyCategories' | 'failoverActions'>) => void;
}) {
const groups = failoverCategoryRoutingGroups(props.allowCategories, props.denyCategories, props.actions);
const options = categoryOptions(props.allowCategories, props.denyCategories, Object.keys(props.actions));
const updateGroup = (group: string, value: string[]) => {
props.onChange(updateFailoverCategoryRouting(props.allowCategories, props.denyCategories, props.actions, group, value));
};
return (
<div className="spanTwo runnerActionMatrix">
<div className="runnerActionIntro">
<strong></strong>
<small></small>
</div>
<div className="runnerActionGrid">
{failoverActionDefinitions.map((action) => (
<label className="runnerActionGroup" key={action.value} data-action={action.value}>
<span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</span>
<AntSelect
allowClear
className="runtimeTagInput"
maxTagCount="responsive"
mode="tags"
options={options}
placeholder="输入错误分类后回车"
tokenSeparators={[',', '\n']}
value={groups[action.value] ?? []}
onChange={(value) => updateGroup(action.value, value)}
/>
</label>
))}
<label className="runnerActionGroup" data-action="deny">
<span>
<strong></strong>
<small></small>
</span>
<AntSelect
allowClear
className="runtimeTagInput"
maxTagCount="responsive"
mode="tags"
options={options}
placeholder="输入错误分类后回车"
tokenSeparators={[',', '\n']}
value={groups.deny ?? []}
onChange={(value) => updateGroup('deny', value)}
/>
</label>
</div>
</div>
);
}
function runnerPolicyToForm(policy: GatewayRunnerPolicy | null): RunnerPolicyForm {
const failover = readObject(policy?.failoverPolicy);
const hardStop = readObject(policy?.hardStopPolicy);
@@ -499,7 +599,7 @@ function runnerFormToPayload(form: RunnerPolicyForm): GatewayRunnerPolicyUpsertR
denyKeywords: cleanTags(form.denyKeywords),
allowStatusCodes: parseNumberTags(form.allowStatusCodes),
denyStatusCodes: parseNumberTags(form.denyStatusCodes),
actions: Object.keys(form.failoverActions).length > 0 ? form.failoverActions : defaultFailoverActions(),
actions: normalizedFailoverActions(form),
},
hardStopPolicy: {
enabled: form.hardStopEnabled,
@@ -552,6 +652,97 @@ function defaultFailoverActions(): Record<string, unknown> {
};
}
function failoverCategoryRoutingGroups(allowCategories: string[], denyCategories: string[], actions: Record<string, unknown>) {
const actionSet = new Set(failoverActionDefinitions.map((item) => item.value));
const assignments = new Map<string, string>();
for (const category of cleanTags(allowCategories)) {
const rawAction = actions[category];
const action = typeof rawAction === 'string' ? rawAction : '';
assignments.set(category, actionSet.has(action as (typeof failoverActionDefinitions)[number]['value']) ? action : 'next');
}
for (const [category, rawAction] of Object.entries(actions)) {
if (assignments.has(category)) continue;
const action = typeof rawAction === 'string' ? rawAction : '';
if (action === 'stop') {
assignments.set(category, 'deny');
} else if (actionSet.has(action as (typeof failoverActionDefinitions)[number]['value'])) {
assignments.set(category, action);
}
}
for (const category of cleanTags(denyCategories)) {
assignments.set(category, 'deny');
}
const groups: Record<string, string[]> = {};
for (const [category, group] of assignments.entries()) {
groups[group] = [...(groups[group] ?? []), category];
}
for (const key of [...failoverActionDefinitions.map((item) => item.value), 'deny']) {
groups[key] = cleanTags(groups[key] ?? []);
}
return groups;
}
function updateFailoverCategoryRouting(
allowCategories: string[],
denyCategories: string[],
actions: Record<string, unknown>,
group: string,
value: string[],
): Pick<RunnerPolicyForm, 'allowCategories' | 'denyCategories' | 'failoverActions'> {
const groups = failoverCategoryRoutingGroups(allowCategories, denyCategories, actions);
groups[group] = cleanTags(value);
const nextActions: Record<string, unknown> = {};
const knownCategories = new Set<string>();
const nextAllowCategories: string[] = [];
for (const action of failoverActionDefinitions) {
for (const category of cleanTags(groups[action.value] ?? [])) {
knownCategories.add(category);
nextAllowCategories.push(category);
if (action.value !== 'next') {
nextActions[category] = action.value;
}
}
}
const nextDenyCategories = cleanTags(groups.deny ?? []);
for (const category of nextDenyCategories) {
knownCategories.add(category);
}
for (const [category, rawAction] of Object.entries(actions)) {
if (!knownCategories.has(category) && typeof rawAction === 'string' && rawAction !== 'next' && rawAction !== 'stop') {
nextActions[category] = rawAction;
}
}
return {
allowCategories: cleanTags(nextAllowCategories),
denyCategories: nextDenyCategories,
failoverActions: nextActions,
};
}
function normalizedFailoverActions(form: RunnerPolicyForm) {
const groups = failoverCategoryRoutingGroups(form.allowCategories, form.denyCategories, form.failoverActions);
const nextActions: Record<string, unknown> = {};
for (const action of failoverActionDefinitions) {
if (action.value === 'next') continue;
for (const category of cleanTags(groups[action.value] ?? [])) {
nextActions[category] = action.value;
}
}
return nextActions;
}
function categoryOptions(...values: string[][]) {
const knownValues = new Set(failoverCategoryOptions.map((option) => option.value));
const options = [...failoverCategoryOptions];
for (const value of values.flat()) {
const tag = String(value).trim();
if (!tag || knownValues.has(tag)) continue;
knownValues.add(tag);
options.push({ label: tag, value: tag });
}
return options;
}
function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
const rateRules = Array.isArray(policy.rateLimitPolicy?.rules) ? policy.rateLimitPolicy.rules : [];
const retry = readObject(policy.retryPolicy);
+71
View File
@@ -1881,6 +1881,71 @@
padding: 8px 10px;
}
.runnerActionMatrix {
display: grid;
gap: 10px;
}
.runnerActionIntro {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.runnerActionIntro strong {
color: var(--text-normal);
}
.runnerActionIntro small,
.runnerActionGroup small {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-regular);
line-height: 1.4;
}
.runnerActionGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.runnerActionGroup {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
}
.runnerActionGroup > span {
display: grid;
gap: 2px;
}
.runnerActionGroup strong {
color: var(--text-normal);
font-size: var(--font-size-sm);
}
.runnerSupplementalRules {
display: grid;
gap: 2px;
padding-top: 6px;
}
.runnerSupplementalRules strong {
color: var(--text-normal);
}
.runnerSupplementalRules small {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
line-height: 1.4;
}
.runtimePolicyActions {
display: flex;
justify-content: flex-end;
@@ -1933,6 +1998,7 @@
.runtimePolicyGrid,
.runtimePolicyFormBody,
.runtimePolicyRows,
.runnerActionGrid,
.accessPermissionGrid,
.accessTreeToolbar,
.apiKeyCreateDialogBody,
@@ -1940,6 +2006,11 @@
grid-template-columns: 1fr;
}
.runnerActionIntro {
align-items: flex-start;
flex-direction: column;
}
.platformModelChoice {
grid-template-columns: 1fr auto;
}