feat: add runner failover policies and traces
This commit is contained in:
+16
-5
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user