feat: add runner failover policies and traces

This commit is contained in:
2026-05-12 02:16:42 +08:00
parent be31923e74
commit 05632172d0
26 changed files with 2033 additions and 140 deletions
+16 -5
View File
@@ -8,6 +8,7 @@ import type {
GatewayApiKey,
GatewayAuditLog,
GatewayNetworkProxyConfig,
GatewayRunnerPolicy,
GatewayTenantUpsertRequest,
GatewayTask,
GatewayUserUpsertRequest,
@@ -43,6 +44,7 @@ import {
deleteUserGroup,
getHealth,
getNetworkProxyConfig,
getRunnerPolicy,
getTask,
getWalletSummary,
listAccessRules,
@@ -58,11 +60,11 @@ import {
listPlatforms,
listPricingRules,
listPricingRuleSets,
listPublicBaseModels,
listPublicCatalogProviders,
listRuntimePolicySets,
listTasks,
listWalletTransactions,
listPublicBaseModels,
listPublicCatalogProviders,
listRateLimitWindows,
listTenants,
listUserGroups,
@@ -135,6 +137,7 @@ type DataKey =
| 'baseModels'
| 'pricingRules'
| 'pricingRuleSets'
| 'runnerPolicy'
| 'runtimePolicySets'
| 'rateLimitWindows'
| 'tenants'
@@ -175,6 +178,7 @@ export function App() {
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
const [pricingRuleSets, setPricingRuleSets] = useState<PricingRuleSet[]>([]);
const [runnerPolicy, setRunnerPolicy] = useState<GatewayRunnerPolicy | null>(null);
const [runtimePolicySets, setRuntimePolicySets] = useState<RuntimePolicySet[]>([]);
const [accessRules, setAccessRules] = useState<GatewayAccessRule[]>([]);
const [auditLogs, setAuditLogs] = useState<GatewayAuditLog[]>([]);
@@ -217,9 +221,10 @@ export function App() {
setPricingRuleSets,
token,
});
const { removeRuntimePolicySet, saveRuntimePolicySet } = useRuntimePolicySetOperations({
const { removeRuntimePolicySet, saveRunnerPolicy, saveRuntimePolicySet } = useRuntimePolicySetOperations({
setCoreMessage,
setCoreState,
setRunnerPolicy,
setRuntimePolicySets,
token,
});
@@ -269,6 +274,7 @@ export function App() {
modelCatalog,
models,
networkProxyConfig,
runnerPolicy,
platforms,
pricingRules,
pricingRuleSets,
@@ -282,7 +288,7 @@ export function App() {
users,
walletAccounts,
walletTransactions,
}), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
}), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
async function refresh(nextToken = token) {
await ensureRouteData(nextToken, true);
@@ -377,6 +383,9 @@ export function App() {
case 'pricingRuleSets':
setPricingRuleSets((await listPricingRuleSets(nextToken)).items);
return;
case 'runnerPolicy':
setRunnerPolicy(await getRunnerPolicy(nextToken));
return;
case 'runtimePolicySets':
setRuntimePolicySets((await listRuntimePolicySets(nextToken)).items);
return;
@@ -774,6 +783,7 @@ export function App() {
setBaseModels([]);
setPricingRules([]);
setPricingRuleSets([]);
setRunnerPolicy(null);
setRuntimePolicySets([]);
setAccessRules([]);
setAuditLogs([]);
@@ -948,6 +958,7 @@ export function App() {
onSavePlatform={savePlatformWithModels}
onSaveProvider={saveProvider}
onSavePricingRuleSet={savePricingRuleSet}
onSaveRunnerPolicy={saveRunnerPolicy}
onSaveRuntimePolicySet={saveRuntimePolicySet}
onBatchAccessRules={batchSaveAccessRules}
onSaveAccessRule={saveAccessRule}
@@ -1127,7 +1138,7 @@ function dataKeysForRoute(
case 'pricing':
return ['pricingRuleSets'];
case 'runtime':
return ['runtimePolicySets'];
return ['runtimePolicySets', 'runnerPolicy'];
case 'baseModels':
return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets'];
case 'platforms':
+17
View File
@@ -10,6 +10,8 @@ import type {
GatewayAccessRuleUpsertRequest,
GatewayApiKey,
GatewayAuditLog,
GatewayRunnerPolicy,
GatewayRunnerPolicyUpsertRequest,
GatewayTenant,
GatewayTenantUpsertRequest,
GatewayNetworkProxyConfig,
@@ -239,6 +241,21 @@ export async function listRuntimePolicySets(token: string): Promise<ListResponse
return request<ListResponse<RuntimePolicySet>>('/api/admin/runtime/policy-sets', { token });
}
export async function getRunnerPolicy(token: string): Promise<GatewayRunnerPolicy> {
return request<GatewayRunnerPolicy>('/api/admin/runtime/runner-policy', { token });
}
export async function updateRunnerPolicy(
token: string,
input: GatewayRunnerPolicyUpsertRequest,
): Promise<GatewayRunnerPolicy> {
return request<GatewayRunnerPolicy>('/api/admin/runtime/runner-policy', {
body: input,
method: 'PATCH',
token,
});
}
export async function createRuntimePolicySet(
token: string,
input: RuntimePolicySetUpsertRequest,
+2
View File
@@ -5,6 +5,7 @@ import type {
GatewayApiKey,
GatewayAuditLog,
GatewayNetworkProxyConfig,
GatewayRunnerPolicy,
GatewayTask,
GatewayTenant,
GatewayUser,
@@ -28,6 +29,7 @@ export interface ConsoleData {
modelCatalog: ModelCatalogResponse;
models: PlatformModel[];
networkProxyConfig: GatewayNetworkProxyConfig | null;
runnerPolicy: GatewayRunnerPolicy | null;
platforms: IntegrationPlatform[];
pricingRules: PricingRule[];
pricingRuleSets: PricingRuleSet[];
@@ -1,14 +1,31 @@
import type { Dispatch, SetStateAction } from 'react';
import type { RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { createRuntimePolicySet, deleteRuntimePolicySet, updateRuntimePolicySet } from '../api';
import type { GatewayRunnerPolicy, GatewayRunnerPolicyUpsertRequest, RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { createRuntimePolicySet, deleteRuntimePolicySet, updateRunnerPolicy, updateRuntimePolicySet } from '../api';
import type { LoadState } from '../types';
export function useRuntimePolicySetOperations(input: {
setCoreMessage: Dispatch<SetStateAction<string>>;
setCoreState: Dispatch<SetStateAction<LoadState>>;
setRunnerPolicy: Dispatch<SetStateAction<GatewayRunnerPolicy | null>>;
setRuntimePolicySets: Dispatch<SetStateAction<RuntimePolicySet[]>>;
token: string;
}) {
async function saveRunnerPolicy(payload: GatewayRunnerPolicyUpsertRequest) {
if (!input.token) throw new Error('请先登录后再维护调度策略');
input.setCoreState('loading');
input.setCoreMessage('');
try {
const policy = await updateRunnerPolicy(input.token, payload);
input.setRunnerPolicy(policy);
input.setCoreState('ready');
input.setCoreMessage('全局调度策略已更新。');
} catch (err) {
input.setCoreState('error');
input.setCoreMessage(err instanceof Error ? err.message : '全局调度策略保存失败');
throw err;
}
}
async function saveRuntimePolicySet(payload: RuntimePolicySetUpsertRequest, policySetId?: string) {
if (!input.token) throw new Error('请先登录后再维护运行策略');
input.setCoreState('loading');
@@ -43,5 +60,5 @@ export function useRuntimePolicySetOperations(input: {
}
}
return { removeRuntimePolicySet, saveRuntimePolicySet };
return { removeRuntimePolicySet, saveRunnerPolicy, saveRuntimePolicySet };
}
+4
View File
@@ -6,6 +6,7 @@ import type {
GatewayAccessRuleBatchRequest,
GatewayAccessRuleUpsertRequest,
GatewayTenantUpsertRequest,
GatewayRunnerPolicyUpsertRequest,
GatewayUserUpsertRequest,
PricingRuleSetUpsertRequest,
RuntimePolicySetUpsertRequest,
@@ -62,6 +63,7 @@ export function AdminPage(props: {
onSavePlatform: (input: PlatformWithModelsInput) => Promise<void>;
onSaveProvider: (input: CatalogProviderUpsertRequest, providerId?: string) => Promise<void>;
onSavePricingRuleSet: (input: PricingRuleSetUpsertRequest, ruleSetId?: string) => Promise<void>;
onSaveRunnerPolicy: (input: GatewayRunnerPolicyUpsertRequest) => Promise<void>;
onSaveRuntimePolicySet: (input: RuntimePolicySetUpsertRequest, policySetId?: string) => Promise<void>;
onSaveAccessRule: (input: GatewayAccessRuleUpsertRequest, ruleId?: string) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
@@ -102,9 +104,11 @@ export function AdminPage(props: {
{props.section === 'runtime' && (
<RuntimePoliciesPanel
message={props.operationMessage}
runnerPolicy={props.data.runnerPolicy}
runtimePolicySets={props.data.runtimePolicySets}
state={props.state}
onDeleteRuntimePolicySet={props.onDeleteRuntimePolicySet}
onSaveRunnerPolicy={props.onSaveRunnerPolicy}
onSaveRuntimePolicySet={props.onSaveRuntimePolicySet}
/>
)}
+102 -2
View File
@@ -851,6 +851,15 @@ function TaskAttemptPopoverContent(props: { task: GatewayTask }) {
</span>
<small>{taskAttemptMeta(attempt)}</small>
{attempt.status === 'failed' && <span className="taskRecordAttemptError">{taskAttemptFailureReason(attempt)}</span>}
{taskAttemptTrace(attempt).length > 0 && (
<span className="taskRecordAttemptTrace">
{taskAttemptTrace(attempt).map((entry, index) => (
<span key={`${attempt.id || attempt.attemptNo}-trace-${index}`} className="taskRecordAttemptTraceItem">
{taskAttemptTraceText(entry)}
</span>
))}
</span>
)}
</span>
))}
</span>
@@ -879,9 +888,11 @@ function taskAttemptStatusText(status: string) {
}
function taskAttemptMeta(attempt: NonNullable<GatewayTask['attempts']>[number]) {
const statusCode = taskAttemptStatusCode(attempt);
const values = [
attempt.providerModelName || attempt.modelName || attempt.modelAlias,
attempt.requestId ? `RequestID ${attempt.requestId}` : '',
statusCode ? `状态码 ${statusCode}` : '',
attempt.responseDurationMs ? formatDuration(attempt.responseDurationMs) : '',
].filter(Boolean);
return values.join(' · ') || attempt.clientId || '-';
@@ -894,8 +905,97 @@ function taskAttemptFailureReason(attempt: NonNullable<GatewayTask['attempts']>[
metadataString(attempt.metrics, 'message'),
);
const code = firstText(attempt.errorCode, metadataString(attempt.metrics, 'errorCode'));
if (detail && code && detail !== code) return `${detail}${code}`;
return detail || code || '失败';
const statusCode = taskAttemptStatusCode(attempt);
const statusText = statusCode ? `状态码 ${statusCode}` : '';
const category = taskAttemptFailureCategory(attempt);
const categoryText = category ? `错误分类 ${category}` : '';
if (detail && code && detail !== code) return [detail, code, statusText, categoryText].filter(Boolean).join(' · ');
return [detail || code || '失败', statusText, categoryText].filter(Boolean).join(' · ');
}
function taskAttemptStatusCode(attempt: NonNullable<GatewayTask['attempts']>[number]) {
const value = attempt.statusCode ?? metadataNumber(attempt.metrics, 'statusCode');
return value && Number.isFinite(value) ? Math.trunc(value) : null;
}
function taskAttemptTrace(attempt: NonNullable<GatewayTask['attempts']>[number]) {
const raw = attempt.metrics?.trace;
if (!Array.isArray(raw)) return [];
return raw.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item));
}
function taskAttemptTraceText(entry: Record<string, unknown>) {
const event = objectString(entry, 'event');
const action = objectString(entry, 'action');
const reason = objectString(entry, 'reason');
const statusCode = metadataNumber(entry, 'statusCode');
const errorCode = objectString(entry, 'errorCode');
const category = objectString(entry, 'category');
const policy = taskAttemptTracePolicy(entry);
const values = [
taskAttemptTraceEventLabel(event, action),
statusCode ? `状态码 ${Math.trunc(statusCode)}` : '',
errorCode ? `错误 ${errorCode}` : '',
category ? `错误分类 ${category}` : '',
policy,
reason ? `原因 ${taskAttemptTraceReasonLabel(reason)}` : '',
].filter(Boolean);
return values.join(' · ');
}
function taskAttemptFailureCategory(attempt: NonNullable<GatewayTask['attempts']>[number]) {
const category = firstText(
metadataString(attempt.metrics, 'errorCategory'),
metadataString(attempt.metrics, 'category'),
);
if (category) return category;
for (const entry of taskAttemptTrace(attempt)) {
const traceCategory = objectString(entry, 'category');
if (traceCategory) return traceCategory;
}
return '';
}
function taskAttemptTracePolicy(entry: Record<string, unknown>) {
const source = objectString(entry, 'policySource');
const policy = objectString(entry, 'policy');
const rule = objectString(entry, 'policyRule');
const matchedValue = objectString(entry, 'matchedValue');
const sourceLabel = source || policy;
const policyPath = [sourceLabel, rule].filter(Boolean).join('.');
if (!policyPath) return '';
return matchedValue ? `策略 ${policyPath}=${matchedValue}` : `策略 ${policyPath}`;
}
function taskAttemptTraceEventLabel(event: string, action: string) {
if (event === 'failure') return '失败';
if (event === 'same_client_retry') return action === 'retry' ? '本平台重试' : '本平台停止重试';
if (event === 'failover_next') return '切换下个平台';
if (event === 'failover_stop') return '停止切换平台';
if (event === 'priority_demoted') return '优先级降级';
return event || action || '链路事件';
}
function taskAttemptTraceReasonLabel(reason: string) {
const labels: Record<string, string> = {
client_call_failed: '客户端调用失败',
retry_disabled: '模型重试关闭',
retry_deny_policy: '命中模型拒绝重试规则',
retry_allow_policy: '命中模型允许重试规则',
client_retryable: '客户端标记可重试',
client_non_retryable: '客户端标记不可重试',
same_client_max_attempts: '达到本平台最大尝试次数',
failover_time_budget_exceeded: '超过全局切换时间预算',
runner_policy_disabled: '全局调度策略停用',
hard_stop_policy: '命中硬拒绝规则',
failover_disabled: '平台切换关闭',
failover_deny_policy: '命中拒绝切换规则',
failover_allow_policy: '命中允许切换规则',
max_platforms_reached: '达到最大平台数',
no_next_platform: '没有更多候选平台',
priority_demote_policy: '命中优先级降级规则',
};
return labels[reason] ?? reason;
}
function formatCellValue(value: unknown) {
@@ -158,7 +158,7 @@ export function PlatformManagementPanel(props: {
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText">Base URL</p>
<p className="mutedText">Base URL</p>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
@@ -303,7 +303,7 @@ export function PlatformManagementPanel(props: {
<Input value={form.defaultDiscountFactor} inputMode="decimal" onChange={(event) => setForm({ ...form, defaultDiscountFactor: event.target.value })} />
</Label>
<ToggleField checked={form.retryEnabled} label="失败后重试下一个客户端" onChange={(checked) => setForm({ ...form, retryEnabled: checked })} />
<ToggleField checked={form.retryEnabled} label="失败后同平台重试" onChange={(checked) => setForm({ ...form, retryEnabled: checked })} />
<Label>
<Input value={form.retryMaxAttempts} inputMode="numeric" disabled={!form.retryEnabled} onChange={(event) => setForm({ ...form, retryMaxAttempts: event.target.value })} />
@@ -370,7 +370,7 @@ function ModelBindingPolicy(props: { form: PlatformWizardForm; onChange: (value:
<ToggleField checked={form.modelOverrideRetry} label="覆盖模型重试策略" onChange={(checked) => onChange({ ...form, modelOverrideRetry: checked })} />
{form.modelOverrideRetry && (
<>
<ToggleField checked={form.modelRetryEnabled} label="模型失败重试" onChange={(checked) => onChange({ ...form, modelRetryEnabled: checked })} />
<ToggleField checked={form.modelRetryEnabled} label="模型同平台重试" onChange={(checked) => onChange({ ...form, modelRetryEnabled: checked })} />
<Label>
<Input value={form.modelRetryMaxAttempts} inputMode="numeric" disabled={!form.modelRetryEnabled} onChange={(event) => onChange({ ...form, modelRetryMaxAttempts: event.target.value })} />
@@ -1119,7 +1119,7 @@ function platformRuntimeSummary(platform: IntegrationPlatform) {
const retryPolicy = platform.retryPolicy ?? {};
const retryEnabled = readBoolean(retryPolicy, 'enabled', true);
const maxAttempts = readNumber(retryPolicy, 'maxAttempts') ?? 2;
return `优先级 ${platform.priority} · ${retryEnabled ? `最多${maxAttempts}` : '不重试'} · ${proxyModeText(readNetworkProxyConfig(platform.config ?? {}).proxyMode)}`;
return `优先级 ${platform.priority} · ${retryEnabled ? `同平台最多${maxAttempts}` : '同平台不重试'} · ${proxyModeText(readNetworkProxyConfig(platform.config ?? {}).proxyMode)}`;
}
function proxyModeText(mode: PlatformWizardForm['proxyMode']) {
+398 -70
View File
@@ -1,9 +1,13 @@
import { useState, type FormEvent } from 'react';
import { Gauge, Pencil, Plus, RotateCcw, ShieldCheck, Trash2 } from 'lucide-react';
import type { RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Textarea } from '../../components/ui';
import { useEffect, useState, type FormEvent } from 'react';
import { Select as AntSelect } from 'antd';
import { Gauge, Pencil, Plus, RotateCcw, Route, Save, ShieldCheck, Trash2 } from 'lucide-react';
import type { GatewayRunnerPolicy, GatewayRunnerPolicyUpsertRequest, RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type RuntimePanelTab = 'model' | 'runner';
type RunnerPolicyStrategy = 'failover' | 'hardStop' | 'priorityDemote';
type RuntimePolicyForm = {
policyKey: string;
name: string;
@@ -13,31 +17,69 @@ type RuntimePolicyForm = {
concurrency: string;
retryEnabled: boolean;
retryMaxAttempts: string;
retryAllowKeywords: string;
retryDenyKeywords: string;
retryAllowKeywords: string[];
retryDenyKeywords: string[];
autoDisableEnabled: boolean;
autoDisableThreshold: string;
autoDisableKeywords: string;
autoDisableKeywords: string[];
degradeEnabled: boolean;
degradeCooldownSeconds: string;
degradeKeywords: string;
degradeKeywords: string[];
metadataJson: string;
status: string;
};
type RunnerPolicyForm = {
name: string;
description: string;
failoverEnabled: boolean;
maxPlatforms: string;
maxDurationSeconds: string;
allowCategories: string[];
denyCategories: string[];
allowCodes: string[];
denyCodes: string[];
allowKeywords: string[];
denyKeywords: string[];
allowStatusCodes: string[];
denyStatusCodes: string[];
failoverActions: Record<string, unknown>;
hardStopEnabled: boolean;
hardStopCategories: string[];
hardStopCodes: string[];
hardStopStatusCodes: string[];
hardStopKeywords: string[];
priorityDemoteEnabled: boolean;
priorityDemoteStep: string;
priorityDemoteCategories: string[];
priorityDemoteCodes: string[];
priorityDemoteStatusCodes: string[];
priorityDemoteKeywords: string[];
metadataJson: string;
status: string;
};
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('');
@@ -80,62 +122,91 @@ export function RuntimePoliciesPanel(props: {
}
}
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>
<p className="mutedText"></p>
</div>
<Button type="button" onClick={openCreateDialog}>
{activeTab === 'model' && <Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</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>
<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>
{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>
<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>
<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"
@@ -172,9 +243,9 @@ export function RuntimePoliciesPanel(props: {
</section>
<section className="runtimePolicySection spanTwo">
<header><strong></strong><span>/</span></header>
<header><strong></strong><span>/</span></header>
<div className="runtimePolicyRows">
<Toggle checked={form.retryEnabled} label="允许失败重试" onChange={(checked) => setForm({ ...form, retryEnabled: checked })} />
<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 })} />
@@ -218,15 +289,244 @@ function Toggle(props: { checked: boolean; label: string; onChange: (checked: bo
);
}
function KeywordField(props: { label: string; value: string; onChange: (value: string) => void }) {
function RunnerPolicyEditor(props: {
form: RunnerPolicyForm;
loading: boolean;
onChange: (form: RunnerPolicyForm) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}) {
const [activeStrategy, setActiveStrategy] = useState<RunnerPolicyStrategy>('failover');
const patch = (next: Partial<RunnerPolicyForm>) => props.onChange({ ...props.form, ...next });
const strategyDefinitions = [
{
value: 'failover' as const,
title: '平台间故障切换',
description: '当前平台内部重试耗尽后是否尝试下一个候选平台',
enabled: props.form.failoverEnabled,
icon: <Route size={15} />,
},
{
value: 'hardStop' as const,
title: '硬拒绝规则',
description: '参数、余额、权限等错误直接失败,不受模型覆盖影响',
enabled: props.form.hardStopEnabled,
icon: <ShieldCheck size={15} />,
},
{
value: 'priorityDemote' as const,
title: '优先级降级',
description: '命中后将失败平台的动态优先级调整到当前最后',
enabled: props.form.priorityDemoteEnabled,
icon: <Gauge size={15} />,
},
];
const activeDefinition = strategyDefinitions.find((item) => item.value === activeStrategy) ?? strategyDefinitions[0];
return (
<Label className="spanTwo">
<Card>
<CardHeader className="runnerPolicyHeader">
<div className="runnerPolicyHeaderText">
<CardTitle>{props.form.name}</CardTitle>
<p className="mutedText">{props.form.description}</p>
</div>
<Label className="runnerPolicyHeaderStatus">
<Select value={props.form.status} onChange={(event) => patch({ status: event.target.value })}>
<option value="active"></option>
<option value="disabled"></option>
</Select>
</Label>
</CardHeader>
<CardContent>
<form className="runtimePolicyFormBody runnerPolicyForm" onSubmit={props.onSubmit}>
<div className="capabilityWorkbench runnerPolicyWorkbench spanTwo">
<aside className="capabilitySidebar">
<div className="capabilitySidebarTitle">
<Route size={15} />
<strong></strong>
</div>
<div className="capabilityList">
{strategyDefinitions.map((item) => (
<button className="capabilityListItem" data-active={item.value === activeStrategy} key={item.value} type="button" onClick={() => setActiveStrategy(item.value)}>
<span>
<strong>{item.title}</strong>
<small>{item.description}</small>
</span>
<Badge variant={item.enabled ? 'success' : 'secondary'}>{item.enabled ? '启用' : '关闭'}</Badge>
</button>
))}
</div>
</aside>
<div className="capabilityFormPanel runnerPolicyDetailPanel">
<header>
{activeDefinition.icon}
<div>
<strong>{activeDefinition.title}</strong>
<small>{activeDefinition.description}</small>
</div>
<span>{activeDefinition.enabled ? '启用' : '关闭'}</span>
</header>
{activeStrategy === 'failover' && (
<div className="runtimePolicyRows runnerPolicyDetailRows">
<Toggle checked={props.form.failoverEnabled} label="启用平台间切换" onChange={(checked) => patch({ failoverEnabled: checked })} />
<Label>
<Input value={props.form.maxPlatforms} inputMode="numeric" onChange={(event) => patch({ maxPlatforms: event.target.value })} />
<span className="runtimeFieldHint"> 99</span>
</Label>
<Label>
<Input value={props.form.maxDurationSeconds} inputMode="numeric" onChange={(event) => patch({ maxDurationSeconds: event.target.value })} />
<span className="runtimeFieldHint"> 600 </span>
</Label>
<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 })} />
</div>
)}
{activeStrategy === 'hardStop' && (
<div className="runtimePolicyRows runnerPolicyDetailRows">
<Toggle checked={props.form.hardStopEnabled} label="启用硬拒绝" onChange={(checked) => patch({ hardStopEnabled: checked })} />
<KeywordField label="硬拒绝分类" value={props.form.hardStopCategories} onChange={(value) => patch({ hardStopCategories: value })} />
<KeywordField label="硬拒绝错误码" value={props.form.hardStopCodes} onChange={(value) => patch({ hardStopCodes: value })} />
<KeywordField label="硬拒绝状态码" value={props.form.hardStopStatusCodes} onChange={(value) => patch({ hardStopStatusCodes: value })} />
<span className="runtimeFieldHint spanTwo">401/403 </span>
<KeywordField label="硬拒绝关键词" value={props.form.hardStopKeywords} onChange={(value) => patch({ hardStopKeywords: value })} />
</div>
)}
{activeStrategy === 'priorityDemote' && (
<div className="runtimePolicyRows runnerPolicyDetailRows">
<Toggle checked={props.form.priorityDemoteEnabled} label="启用优先级降级" onChange={(checked) => patch({ priorityDemoteEnabled: checked })} />
<Label>
<Input value={props.form.priorityDemoteStep} inputMode="numeric" onChange={(event) => patch({ priorityDemoteStep: event.target.value })} />
<span className="runtimeFieldHint"> 100</span>
</Label>
<KeywordField label="降级分类" value={props.form.priorityDemoteCategories} onChange={(value) => patch({ priorityDemoteCategories: value })} />
<KeywordField label="降级错误码" value={props.form.priorityDemoteCodes} onChange={(value) => patch({ priorityDemoteCodes: value })} />
<KeywordField label="降级状态码" value={props.form.priorityDemoteStatusCodes} onChange={(value) => patch({ priorityDemoteStatusCodes: value })} />
<KeywordField label="降级关键词" value={props.form.priorityDemoteKeywords} onChange={(value) => patch({ priorityDemoteKeywords: value })} />
</div>
)}
</div>
</div>
<Label className="spanTwo"> JSON<Textarea value={props.form.metadataJson} rows={4} onChange={(event) => patch({ metadataJson: event.target.value })} /></Label>
<div className="runtimePolicyActions spanTwo">
<Button type="submit" disabled={props.loading}>
<Save size={15} />
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
function KeywordField(props: { label: string; value: string[]; onChange: (value: string[]) => void }) {
const options = props.value.map((item) => ({ label: item, value: item }));
return (
<Label className="spanTwo runtimeTagField">
{props.label}
<Input value={props.value} placeholder="多个关键词用逗号或换行分隔" onChange={(event) => props.onChange(event.target.value)} />
<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);
return {
name: policy?.name ?? '默认全局调度策略',
description: policy?.description ?? '控制多个候选平台之间的故障切换;模型运行策略只可覆盖 failoverPolicy,不能覆盖 hardStopPolicy。',
failoverEnabled: readBool(failover.enabled, true),
maxPlatforms: String(readNumber(failover.maxPlatforms, 99)),
maxDurationSeconds: String(readNumber(failover.maxDurationSeconds, 600)),
allowCategories: tagsFromValue(failover.allowCategories ?? ['network', 'timeout', 'stream_error', 'rate_limit', 'provider_5xx', 'provider_overloaded', 'auth_error']),
denyCategories: tagsFromValue(failover.denyCategories ?? ['request_error', 'unsupported_model', 'user_permission', 'insufficient_balance']),
allowCodes: tagsFromValue(failover.allowCodes ?? ['auth_failed', 'invalid_api_key', 'missing_credentials']),
denyCodes: tagsFromValue(failover.denyCodes ?? []),
allowKeywords: tagsFromValue(failover.allowKeywords ?? ['timeout', 'network', 'rate_limit', 'overloaded', 'temporarily_unavailable', 'server_error', 'auth_failed', 'invalid_api_key', 'missing_credentials', 'unauthorized', 'forbidden', '429', '5xx']),
denyKeywords: tagsFromValue(failover.denyKeywords ?? ['invalid_parameter', 'missing required', 'bad request']),
allowStatusCodes: tagsFromValue(failover.allowStatusCodes ?? [401, 403, 408, 429, 500, 502, 503, 504]),
denyStatusCodes: tagsFromValue(failover.denyStatusCodes ?? []),
failoverActions: Object.keys(readObject(failover.actions)).length > 0 ? readObject(failover.actions) : defaultFailoverActions(),
hardStopEnabled: readBool(hardStop.enabled, true),
hardStopCategories: tagsFromValue(hardStop.categories ?? ['request_error', 'unsupported_model', 'user_permission', 'insufficient_balance']),
hardStopCodes: tagsFromValue(hardStop.codes ?? ['bad_request', 'invalid_request', 'invalid_parameter', 'missing_required', 'unsupported_kind', 'unsupported_model', 'insufficient_balance', 'permission_denied']),
hardStopStatusCodes: tagsFromValue(hardStop.statusCodes ?? []),
hardStopKeywords: tagsFromValue(hardStop.keywords ?? ['invalid_parameter', 'missing required', 'bad request', 'insufficient balance']),
priorityDemoteEnabled: readBool(priorityDemote.enabled, true),
priorityDemoteStep: String(readNumber(priorityDemote.demoteStep, 100)),
priorityDemoteCategories: tagsFromValue(priorityDemote.categories ?? ['network', 'timeout', 'stream_error', 'rate_limit', 'provider_5xx', 'provider_overloaded']),
priorityDemoteCodes: tagsFromValue(priorityDemote.codes ?? ['network', 'timeout', 'stream_read_error', 'rate_limit', 'server_error', 'overloaded']),
priorityDemoteStatusCodes: tagsFromValue(priorityDemote.statusCodes ?? [408, 429, 500, 502, 503, 504]),
priorityDemoteKeywords: tagsFromValue(priorityDemote.keywords ?? ['timeout', 'network', 'rate_limit', 'overloaded', 'temporarily_unavailable', 'server_error', '429', '5xx']),
metadataJson: JSON.stringify(policy?.metadata ?? {}, null, 2),
status: policy?.status ?? 'active',
};
}
function runnerFormToPayload(form: RunnerPolicyForm): GatewayRunnerPolicyUpsertRequest {
return {
policyKey: 'default-runner-v1',
name: form.name.trim() || '默认全局调度策略',
description: form.description.trim() || undefined,
failoverPolicy: {
enabled: form.failoverEnabled,
maxPlatforms: positiveInt(form.maxPlatforms, 99),
maxDurationSeconds: positiveInt(form.maxDurationSeconds, 600),
allowCategories: cleanTags(form.allowCategories),
denyCategories: cleanTags(form.denyCategories),
allowCodes: cleanTags(form.allowCodes),
denyCodes: cleanTags(form.denyCodes),
allowKeywords: cleanTags(form.allowKeywords),
denyKeywords: cleanTags(form.denyKeywords),
allowStatusCodes: parseNumberTags(form.allowStatusCodes),
denyStatusCodes: parseNumberTags(form.denyStatusCodes),
actions: Object.keys(form.failoverActions).length > 0 ? form.failoverActions : defaultFailoverActions(),
},
hardStopPolicy: {
enabled: form.hardStopEnabled,
categories: cleanTags(form.hardStopCategories),
codes: cleanTags(form.hardStopCodes),
statusCodes: parseNumberTags(form.hardStopStatusCodes),
keywords: cleanTags(form.hardStopKeywords),
},
priorityDemotePolicy: {
enabled: form.priorityDemoteEnabled,
demoteStep: positiveInt(form.priorityDemoteStep, 100),
categories: cleanTags(form.priorityDemoteCategories),
codes: cleanTags(form.priorityDemoteCodes),
statusCodes: parseNumberTags(form.priorityDemoteStatusCodes),
keywords: cleanTags(form.priorityDemoteKeywords),
},
metadata: parseJson(form.metadataJson),
status: form.status.trim() || 'active',
};
}
function createDefaultForm(policyKey = 'default-runtime-v1'): RuntimePolicyForm {
return {
policyKey,
@@ -237,19 +537,28 @@ function createDefaultForm(policyKey = 'default-runtime-v1'): RuntimePolicyForm
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',
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',
autoDisableKeywords: ['invalid_api_key', 'account_deactivated', 'permission_denied', 'billing_not_active'],
degradeEnabled: true,
degradeCooldownSeconds: '300',
degradeKeywords: 'rate_limit, quota, timeout, temporarily_unavailable, overloaded',
degradeKeywords: ['rate_limit', 'quota', 'timeout', 'temporarily_unavailable', 'overloaded'],
metadataJson: '{}',
status: 'active',
};
}
function defaultFailoverActions(): Record<string, unknown> {
return {
auth_error: 'disable_and_next',
rate_limit: 'cooldown_and_next',
provider_5xx: 'next',
request_error: 'stop',
};
}
function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
const rateRules = Array.isArray(policy.rateLimitPolicy?.rules) ? policy.rateLimitPolicy.rules : [];
const retry = readObject(policy.retryPolicy);
@@ -264,14 +573,14 @@ function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
concurrency: String(readRateLimit(rateRules, 'concurrent') || ''),
retryEnabled: readBool(retry.enabled, true),
retryMaxAttempts: String(readNumber(retry.maxAttempts, 2)),
retryAllowKeywords: stringifyKeywords(retry.allowKeywords),
retryDenyKeywords: stringifyKeywords(retry.denyKeywords),
retryAllowKeywords: tagsFromValue(retry.allowKeywords),
retryDenyKeywords: tagsFromValue(retry.denyKeywords),
autoDisableEnabled: readBool(disable.enabled, false),
autoDisableThreshold: String(readNumber(disable.threshold, 3)),
autoDisableKeywords: stringifyKeywords(disable.keywords),
autoDisableKeywords: tagsFromValue(disable.keywords),
degradeEnabled: readBool(degrade.enabled, true),
degradeCooldownSeconds: String(readNumber(degrade.cooldownSeconds, 300)),
degradeKeywords: stringifyKeywords(degrade.keywords),
degradeKeywords: tagsFromValue(degrade.keywords),
metadataJson: JSON.stringify(policy.metadata ?? {}, null, 2),
status: policy.status || 'active',
};
@@ -286,18 +595,18 @@ function formToPayload(form: RuntimePolicyForm): RuntimePolicySetUpsertRequest {
retryPolicy: {
enabled: form.retryEnabled,
maxAttempts: positiveInt(form.retryMaxAttempts, 2),
allowKeywords: parseKeywords(form.retryAllowKeywords),
denyKeywords: parseKeywords(form.retryDenyKeywords),
allowKeywords: cleanTags(form.retryAllowKeywords),
denyKeywords: cleanTags(form.retryDenyKeywords),
},
autoDisablePolicy: {
enabled: form.autoDisableEnabled,
threshold: positiveInt(form.autoDisableThreshold, 3),
keywords: parseKeywords(form.autoDisableKeywords),
keywords: cleanTags(form.autoDisableKeywords),
},
degradePolicy: {
enabled: form.degradeEnabled,
cooldownSeconds: positiveInt(form.degradeCooldownSeconds, 300),
keywords: parseKeywords(form.degradeKeywords),
keywords: cleanTags(form.degradeKeywords),
},
metadata: parseJson(form.metadataJson),
status: form.status.trim() || 'active',
@@ -332,7 +641,7 @@ function rateLimitSummary(policy: RuntimePolicySet) {
function retrySummary(policy: RuntimePolicySet) {
const retry = readObject(policy.retryPolicy);
return readBool(retry.enabled, false) ? `${readNumber(retry.maxAttempts, 2)}` : '不重试';
return readBool(retry.enabled, false) ? `同平台尝${readNumber(retry.maxAttempts, 2)}` : '同平台不重试';
}
function autoDisableSummary(policy: RuntimePolicySet) {
@@ -350,12 +659,31 @@ function readRateLimit(rules: unknown[], metric: string) {
return readNumber(readObject(rule).limit, 0);
}
function parseKeywords(value: string) {
return value.split(/[,\n]/).map((item) => item.trim()).filter(Boolean);
function stringifyKeywords(value: unknown) {
return tagsFromValue(value).join(', ');
}
function stringifyKeywords(value: unknown) {
return Array.isArray(value) ? value.map(String).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) {
+13
View File
@@ -424,6 +424,19 @@ strong {
overflow-wrap: anywhere;
}
.taskRecordAttemptTrace {
display: grid;
gap: 0.25rem;
padding-top: 0.1rem;
}
.taskRecordAttemptTraceItem {
color: var(--text-soft);
font-size: var(--font-size-xs);
line-height: 1.45;
overflow-wrap: anywhere;
}
.taskRecordJsonButton {
width: 100%;
justify-content: flex-start;
+74
View File
@@ -1536,6 +1536,70 @@
gap: 10px;
}
.runtimeTagInput {
width: 100%;
}
.runtimeTagInput .ant-select-selector {
min-height: 2.375rem;
border-color: var(--border) !important;
border-radius: 0.5rem !important;
background: var(--background) !important;
box-shadow: none !important;
}
.runtimeTagInput .ant-select-selection-placeholder {
color: var(--muted-foreground);
}
.runtimeTagInput .ant-select-selection-item {
border-color: var(--border);
border-radius: 999px;
background: var(--surface-subtle);
}
.runtimeFieldHint {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-regular);
line-height: 1.45;
}
.runnerPolicyForm {
padding: 0;
background: transparent;
}
.runnerPolicyHeaderText {
min-width: 0;
}
.runnerPolicyHeaderText .mutedText {
margin: 4px 0 0;
}
.runnerPolicyHeaderStatus {
min-width: 220px;
max-width: 240px;
}
.runnerPolicyWorkbench {
grid-template-columns: 240px minmax(0, 1fr);
}
.runnerPolicyDetailPanel {
min-height: 360px;
}
.runnerPolicyDetailRows {
padding: 8px 10px;
}
.runtimePolicyActions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 1180px) {
.modelCards,
.providerCatalogGrid,
@@ -1549,6 +1613,16 @@
}
@media (max-width: 860px) {
.runnerPolicyHeader {
align-items: flex-start;
flex-direction: column;
}
.runnerPolicyHeaderStatus {
width: 100%;
max-width: none;
}
.subPageLayout,
.modelsPage {
grid-template-columns: 1fr;