feat(routing): add strategy failover controls

This commit is contained in:
elky
2026-09-09 09:12:09 +08:00
parent e58570d79d
commit f2839ae6a7
31 changed files with 1881 additions and 160 deletions
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, h, nextTick, ref, type App } from 'vue'
import RoutingFailoverPolicyEditor from '../components/RoutingFailoverPolicyEditor.vue'
import { normalizeRoutingFailoverPolicy, type RoutingFailoverPolicy } from '../utils/routingFailover'
const mounted: Array<{ app: App, root: HTMLElement }> = []
function mountEditor() {
const policy = ref(normalizeRoutingFailoverPolicy())
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
setup: () => () => h(RoutingFailoverPolicyEditor, {
modelValue: policy.value,
'onUpdate:modelValue': (value: RoutingFailoverPolicy) => { policy.value = value },
}),
})
app.mount(root)
mounted.push({ app, root })
return { root, policy }
}
function control<T extends HTMLElement>(root: HTMLElement, label: string): T {
const element = root.querySelector<T>(`[aria-label="${label}"]`)
if (!element) throw new Error(`Missing control: ${label}`)
return element
}
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
})
describe('RoutingFailoverPolicyEditor', () => {
it('edits independent global budgets and documents sticky retry exclusion', async () => {
const { root, policy } = mountEditor()
expect(root.textContent).toContain('首次尝试和粘性同 Key 重试不计入')
expect(root.textContent).toContain('不会中断已开始的调用')
const count = control<HTMLInputElement>(root, '全局最大转移次数')
count.value = '4'
count.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(policy.value.max_transfer_count).toBe(4)
expect(policy.value.max_transfer_timeout_seconds).toBe(0)
})
it('adds regex and status-only rules and reports invalid drafts', async () => {
const { root, policy } = mountEditor()
control<HTMLButtonElement>(root, '添加成功转移规则').click()
await nextTick()
expect(root.querySelector('[role="alert"]')?.textContent).toContain('正则表达式')
const regex = control<HTMLInputElement>(root, '成功转移规则 1 正则')
regex.value = '(?i)capacity.*exhausted'
regex.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(policy.value.failover_rules.success_failover_patterns[0].pattern).toBe('(?i)capacity.*exhausted')
control<HTMLButtonElement>(root, '添加错误提前终止规则').click()
await nextTick()
const statuses = control<HTMLInputElement>(root, '终止规则 1 状态码')
statuses.value = '400, 413'
statuses.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(policy.value.failover_rules.error_stop_patterns[0].status_codes).toEqual([400, 413])
expect(root.querySelector('[role="alert"]')).toBeNull()
control<HTMLButtonElement>(root, '删除成功转移规则 1').click()
await nextTick()
expect(policy.value.failover_rules.success_failover_patterns).toHaveLength(0)
})
it('edits and applies both rule groups through JSON mode', async () => {
const { root, policy } = mountEditor()
control<HTMLButtonElement>(root, '切到成功转移规则 JSON').click()
await nextTick()
const successJson = root.querySelector<HTMLTextAreaElement>('textarea')
if (!successJson) throw new Error('Missing success JSON editor')
successJson.value = '[{"pattern":"capacity"}]'
successJson.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
control<HTMLButtonElement>(root, '切回成功转移规则表单').click()
await nextTick()
expect(policy.value.failover_rules.success_failover_patterns).toEqual([{ pattern: 'capacity', status_codes: [] }])
control<HTMLButtonElement>(root, '切到错误提前终止规则 JSON').click()
await nextTick()
const errorJson = root.querySelector<HTMLTextAreaElement>('textarea')
if (!errorJson) throw new Error('Missing error JSON editor')
errorJson.value = '[{"status_codes":[429,500],"pattern":"rate"}]'
errorJson.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
control<HTMLButtonElement>(root, '切回错误提前终止规则表单').click()
await nextTick()
expect(policy.value.failover_rules.error_stop_patterns).toEqual([{ pattern: 'rate', status_codes: [429, 500] }])
})
it('keeps invalid JSON visible until it is corrected', async () => {
const { root, policy } = mountEditor()
control<HTMLButtonElement>(root, '切到错误提前终止规则 JSON').click()
await nextTick()
const editor = root.querySelector<HTMLTextAreaElement>('textarea')
if (!editor) throw new Error('Missing JSON editor')
editor.value = '{'
editor.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
control<HTMLButtonElement>(root, '切回错误提前终止规则表单').click()
await nextTick()
expect(root.querySelector('[role="alert"]')?.textContent).toContain('JSON')
expect(policy.value.failover_rules.error_stop_patterns).toHaveLength(0)
})
})
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { normalizeRoutingFailoverPolicy, validateRoutingFailoverPolicy } from '../utils/routingFailover'
import { createEmptyRoutingGroupConfig, getModelScheduling, normalizeRoutingGroupConfig, upsertModelSchedulingRule } from '../utils/routingPolicy'
describe('routing failover policy', () => {
it('keeps legacy strategies unlimited with empty global rules', () => {
const policy = normalizeRoutingGroupConfig({}).default_policy
expect(policy.max_transfer_count).toBe(0)
expect(policy.max_transfer_timeout_seconds).toBe(0)
expect(policy.failover_rules).toEqual({ success_failover_patterns: [], error_stop_patterns: [] })
expect(validateRoutingFailoverPolicy(policy)).toBeNull()
})
it('preserves global limits and rules across model edits without sharing mutable arrays', () => {
const config = createEmptyRoutingGroupConfig()
Object.assign(config.default_policy, {
max_transfer_count: 3,
max_transfer_timeout_seconds: 90,
failover_rules: {
success_failover_patterns: [{ pattern: '(?i)capacity', status_codes: [] }],
error_stop_patterns: [{ pattern: '', status_codes: [400, 413] }],
},
})
const updated = upsertModelSchedulingRule(config, 'model-a', { priority_mode: 'provider', scheduling_mode: 'fixed_order' })
const policy = getModelScheduling(updated, 'model-a')
expect(policy.max_transfer_count).toBe(3)
expect(policy.max_transfer_timeout_seconds).toBe(90)
expect(policy.failover_rules).toEqual(config.default_policy.failover_rules)
expect(validateRoutingFailoverPolicy(policy)).toBeNull()
policy.failover_rules.error_stop_patterns[0].status_codes.push(422)
expect(config.default_policy.failover_rules.error_stop_patterns[0].status_codes).toEqual([400, 413])
})
it('rejects invalid budgets and ambiguous empty rules before saving', () => {
const policy = normalizeRoutingFailoverPolicy()
policy.max_transfer_count = -1
expect(validateRoutingFailoverPolicy(policy)).toContain('非负整数')
policy.max_transfer_count = 0
policy.max_transfer_timeout_seconds = 0.5
expect(validateRoutingFailoverPolicy(policy)).toContain('非负整数')
policy.max_transfer_timeout_seconds = 0
policy.failover_rules.error_stop_patterns.push({ pattern: '', status_codes: [] })
expect(validateRoutingFailoverPolicy(policy)).toContain('状态码或正则')
policy.failover_rules.error_stop_patterns[0].status_codes = [200]
expect(validateRoutingFailoverPolicy(policy)).toContain('400–599')
policy.failover_rules.error_stop_patterns[0].status_codes = [400]
expect(validateRoutingFailoverPolicy(policy)).toBeNull()
})
})
@@ -91,7 +91,7 @@ describe('routingPolicy', () => {
expect(createEmptyRoutingGroupConfig().default_policy.sticky_key_attempts).toBe(2)
expect(normalizeRoutingGroupConfig({}).default_policy.sticky_key_attempts).toBe(2)
expect(normalizeRoutingGroupConfig({
default_policy: { priority_mode: 'provider', scheduling_mode: 'cache_affinity', keep_priority_on_conversion: false, sticky_key_attempts: 3, enable_cf_heartbeat: false, cyber_continue_failover: false, cancel_on_client_disconnect: false },
default_policy: { ...createEmptyRoutingGroupConfig().default_policy, priority_mode: 'provider', scheduling_mode: 'cache_affinity', keep_priority_on_conversion: false, sticky_key_attempts: 3, enable_cf_heartbeat: false, cyber_continue_failover: false, cancel_on_client_disconnect: false },
}).default_policy.sticky_key_attempts).toBe(3)
expect(normalizeStickyKeyAttempts('5')).toBe(5)
expect(normalizeStickyKeyAttempts(-1)).toBe(2)
@@ -0,0 +1,373 @@
<template>
<section
class="space-y-4 rounded-lg border border-border/60 p-4"
data-testid="routing-failover-policy"
>
<div>
<h3 class="text-sm font-medium">
故障转移规则
</h3>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
作用于当前调度策略的所有提供商。先检查全局规则,再检查提供商自身规则;业务内容输出后不再重放请求。
</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="space-y-1.5 text-sm">
<span>全局最大转移次数</span>
<Input
:model-value="modelValue.max_transfer_count"
:disabled="disabled"
type="number"
min="0"
step="1"
aria-label="全局最大转移次数"
@update:model-value="updateLimit('max_transfer_count', $event)"
/>
<span class="block text-xs leading-relaxed text-muted-foreground">0 不限制。首次尝试和粘性同 Key 重试不计入;每次切换候选计 1 次。</span>
</label>
<label class="space-y-1.5 text-sm">
<span>全局最大转移时间(秒)</span>
<Input
:model-value="modelValue.max_transfer_timeout_seconds"
:disabled="disabled"
type="number"
min="0"
step="1"
aria-label="全局最大转移时间"
@update:model-value="updateLimit('max_transfer_timeout_seconds', $event)"
/>
<span class="block text-xs leading-relaxed text-muted-foreground">0 不限制。从首次尝试累计,耗尽后不再启动下一次尝试;不会中断已开始的调用,单次超时仍独立生效。</span>
</label>
</div>
<div
v-for="section in ruleSections"
:key="section.key"
class="space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h4 class="text-sm font-medium">
{{ section.title }}
</h4>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
{{ section.description }}
</p>
</div>
<div class="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:title="jsonMode[section.key] ? `切回${section.title}表单` : `切到${section.title} JSON`"
:aria-label="jsonMode[section.key] ? `切回${section.title}表单` : `切到${section.title} JSON`"
@click="toggleJsonMode(section.key)"
>
<Code2 class="mr-1 h-3 w-3" />
{{ jsonMode[section.key] ? '表单' : 'JSON' }}
</Button>
<Button
v-if="jsonMode[section.key]"
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:title="`格式化${section.title} JSON`"
:aria-label="`格式化${section.title} JSON`"
@click="formatJsonDraft(section.key)"
>
<AlignLeft class="mr-1 h-3 w-3" />格式化
</Button>
<Button
v-if="!jsonMode[section.key]"
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="disabled || modelValue.failover_rules[section.key].length >= MAX_ROUTING_FAILOVER_RULES"
:aria-label="`添加${section.title}`"
@click="addRule(section.key)"
>
<Plus class="mr-1 h-3 w-3" />添加
</Button>
</div>
</div>
<div
v-if="jsonMode[section.key]"
class="space-y-2"
>
<Textarea
:model-value="jsonDraft[section.key]"
class="min-h-[160px] font-mono text-xs leading-relaxed"
:disabled="disabled"
spellcheck="false"
:placeholder="jsonPlaceholder(section.key)"
@update:model-value="updateJsonDraft(section.key, $event)"
/>
<div
v-if="jsonError[section.key]"
role="alert"
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
{{ jsonError[section.key] }}
</div>
<p class="text-xs text-muted-foreground">
仅管理{{ section.title }};JSON 应为数组,<code class="rounded bg-muted px-1">pattern</code>{{ section.key === 'success_failover_patterns' ? ' 必填。' : ' 和 status_codes 至少填写一个。' }}
</p>
</div>
<template v-else>
<p
v-if="modelValue.failover_rules[section.key].length === 0"
class="rounded-md border border-dashed p-3 text-xs text-muted-foreground"
>
暂无规则
</p>
<div
v-for="(rule, index) in modelValue.failover_rules[section.key]"
:key="index"
class="flex min-w-0 items-center gap-1"
>
<Input
v-if="section.key === 'error_stop_patterns'"
:model-value="rule.status_codes.join(',')"
:disabled="disabled"
size="sm"
class="w-full shrink-0 font-mono text-xs sm:w-40"
:aria-label="`终止规则 ${index + 1} 状态码`"
placeholder="状态码(选填,可多个)"
@update:model-value="updateStatuses(index, String($event))"
/>
<Input
:model-value="rule.pattern"
:disabled="disabled"
size="sm"
class="min-w-0 flex-1 font-mono text-xs"
:aria-label="`${section.title} ${index + 1} 正则`"
:placeholder="section.key === 'success_failover_patterns' ? '(?i)capacity.*exhausted' : '正则内容(选填)'"
@update:model-value="updateRule(section.key, index, { pattern: String($event) })"
/>
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-8 shrink-0 p-0"
:disabled="disabled"
:aria-label="`删除${section.title} ${index + 1}`"
@click="removeRule(section.key, index)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</template>
</div>
<p class="text-xs text-muted-foreground">
正则支持 (?i) 忽略大小写;服务端在保存时校验语法。每组最多 64 条,每条正则最多 4096 字节。
</p>
<p
v-if="validationError"
role="alert"
class="text-sm text-destructive"
>
{{ validationError }}
</p>
</section>
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { AlignLeft, Code2, Plus, Trash2 } from 'lucide-vue-next'
import { Button, Input, Textarea } from '@/components/ui'
import {
MAX_ROUTING_FAILOVER_PATTERN_BYTES,
MAX_ROUTING_FAILOVER_RULES,
validateRoutingFailoverPolicy,
type RoutingFailoverPolicy,
type RoutingFailoverRule,
type RoutingFailoverRules,
} from '../utils/routingFailover'
const props = defineProps<{ modelValue: RoutingFailoverPolicy, disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: RoutingFailoverPolicy] }>()
type RuleSection = 'success_failover_patterns' | 'error_stop_patterns'
const jsonMode = reactive<Record<RuleSection, boolean>>({
success_failover_patterns: false,
error_stop_patterns: false,
})
const jsonDraft = reactive<Record<RuleSection, string>>({
success_failover_patterns: '',
error_stop_patterns: '',
})
const jsonError = reactive<Record<RuleSection, string | null>>({
success_failover_patterns: null,
error_stop_patterns: null,
})
const jsonDirty = reactive<Record<RuleSection, boolean>>({
success_failover_patterns: false,
error_stop_patterns: false,
})
const ruleSections: Array<{ key: RuleSection, title: string, description: string }> = [
{ key: 'success_failover_patterns', title: '成功转移规则', description: 'HTTP 200 的响应体或流式输出前的缓冲内容命中正则时,放弃当前候选并继续转移;不是对所有 200 都重试。' },
{ key: 'error_stop_patterns', title: '错误提前终止规则', description: '状态码与正则同时满足时立即终止。可只填状态码,或只填正则匹配全部 400–599 错误;对流内错误使用解析后的错误状态。' },
]
const validationError = computed(() => validateRoutingFailoverPolicy(props.modelValue))
function updateLimit(field: 'max_transfer_count' | 'max_transfer_timeout_seconds', value: string | number) {
emit('update:modelValue', { ...props.modelValue, [field]: Number(value) })
}
function updateRules(patch: Partial<RoutingFailoverRules>) {
emit('update:modelValue', { ...props.modelValue, failover_rules: { ...props.modelValue.failover_rules, ...patch } })
}
function jsonPlaceholder(section: RuleSection): string {
return section === 'success_failover_patterns'
? '[{ "pattern": "(?i)capacity.*exhausted" }]'
: '[{ "status_codes": [400, 413], "pattern": "invalid.*parameter" }]'
}
function stringifyRules(rules: RoutingFailoverRule[]): string {
return JSON.stringify(rules, null, 2)
}
function refreshJsonDraft(section: RuleSection) {
jsonDraft[section] = stringifyRules(props.modelValue.failover_rules[section])
jsonError[section] = null
jsonDirty[section] = false
}
function updateJsonDraft(section: RuleSection, value: string) {
jsonDraft[section] = value
jsonDirty[section] = true
jsonError[section] = null
}
function parseJsonStatusCodes(value: unknown, section: RuleSection, index: number): number[] | string {
if (value === undefined || value === null) return []
if (!Array.isArray(value)) return `${section} 第 ${index + 1} 条的 status_codes 必须是数组`
const statusCodes: number[] = []
for (const status of value) {
if (!Number.isInteger(status)) return `${section} 第 ${index + 1} 条的 status_codes 只能包含整数`
if (section === 'success_failover_patterns' ? status !== 200 : status < 400 || status > 599) {
return `${section} 第 ${index + 1} 条的 status_codes 包含无效状态码`
}
if (!statusCodes.includes(status)) statusCodes.push(status)
}
return statusCodes
}
function parseJsonRules(section: RuleSection, draft: string): RoutingFailoverRule[] | string {
let parsed: unknown
try {
parsed = JSON.parse(draft.trim() || '[]')
} catch (error) {
return error instanceof Error ? error.message : 'JSON 格式无效'
}
let entries: unknown = parsed
if (!Array.isArray(parsed) && parsed !== null && typeof parsed === 'object') {
const root = parsed as Record<string, unknown>
const nested = root.failover_rules
const source = nested !== null && typeof nested === 'object' && !Array.isArray(nested)
? nested as Record<string, unknown>
: root
entries = source[section] ?? []
}
if (!Array.isArray(entries)) return `${section} JSON 必须是数组`
if (entries.length > MAX_ROUTING_FAILOVER_RULES) return `${section} 最多 ${MAX_ROUTING_FAILOVER_RULES} 条`
const rules: RoutingFailoverRule[] = []
for (const [index, entry] of entries.entries()) {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
return `${section} 第 ${index + 1} 条必须是对象`
}
const rawRule = entry as Record<string, unknown>
if (rawRule.pattern !== undefined && typeof rawRule.pattern !== 'string') {
return `${section} 第 ${index + 1} 条的 pattern 必须是字符串`
}
const pattern = typeof rawRule.pattern === 'string' ? rawRule.pattern.trim() : ''
if (new TextEncoder().encode(pattern).length > MAX_ROUTING_FAILOVER_PATTERN_BYTES) {
return `${section} 第 ${index + 1} 条正则过长`
}
const statusCodes = parseJsonStatusCodes(rawRule.status_codes, section, index)
if (typeof statusCodes === 'string') return statusCodes
if (!pattern && (section === 'success_failover_patterns' || statusCodes.length === 0)) {
return `${section} 第 ${index + 1} 条需要${section === 'success_failover_patterns' ? '正则表达式' : '状态码或正则表达式'}`
}
rules.push({ pattern, status_codes: statusCodes })
}
return rules
}
function applyJsonDraft(section: RuleSection): boolean {
const parsed = parseJsonRules(section, jsonDraft[section])
if (typeof parsed === 'string') {
jsonError[section] = parsed
return false
}
updateRules({ [section]: parsed })
jsonDraft[section] = stringifyRules(parsed)
jsonError[section] = null
jsonDirty[section] = false
return true
}
function toggleJsonMode(section: RuleSection) {
if (jsonMode[section]) {
if (jsonDirty[section] && !applyJsonDraft(section)) return
jsonMode[section] = false
return
}
refreshJsonDraft(section)
jsonMode[section] = true
}
function formatJsonDraft(section: RuleSection) {
const parsed = parseJsonRules(section, jsonDraft[section])
if (typeof parsed === 'string') {
jsonError[section] = parsed
return
}
const formatted = stringifyRules(parsed)
if (formatted !== jsonDraft[section]) jsonDirty[section] = true
jsonDraft[section] = formatted
jsonError[section] = null
}
function addRule(section: RuleSection) {
updateRules({ [section]: [...props.modelValue.failover_rules[section], { pattern: '', status_codes: [] }] })
}
function updateRule(section: RuleSection, index: number, patch: Partial<RoutingFailoverRule>) {
updateRules({ [section]: props.modelValue.failover_rules[section].map((rule, position) => position === index ? { ...rule, ...patch } : rule) })
}
function updateStatuses(index: number, value: string | number) {
const input = String(value).trim()
updateRule('error_stop_patterns', index, { status_codes: input ? input.split(/[,,\s]+/).map(Number) : [] })
}
function removeRule(section: RuleSection, index: number) {
updateRules({ [section]: props.modelValue.failover_rules[section].filter((_, position) => position !== index) })
}
function commitJsonDrafts(): boolean {
const parsedRules = new Map<RuleSection, RoutingFailoverRule[]>()
for (const section of ruleSections.map(item => item.key)) {
if (!jsonMode[section] || !jsonDirty[section]) continue
const parsed = parseJsonRules(section, jsonDraft[section])
if (typeof parsed === 'string') {
jsonError[section] = parsed
return false
}
parsedRules.set(section, parsed)
}
for (const [section, rules] of parsedRules) {
updateRules({ [section]: rules })
jsonDraft[section] = stringifyRules(rules)
jsonDirty[section] = false
}
return true
}
defineExpose({ commitJsonDrafts })
</script>
@@ -1,5 +1,6 @@
export { default as RoutingDryRunDialog } from './RoutingDryRunDialog.vue'
export { default as RoutingGroupEditor } from './RoutingGroupEditor.vue'
export { default as RoutingFailoverPolicyEditor } from './RoutingFailoverPolicyEditor.vue'
export { default as RoutingGroupList } from './RoutingGroupList.vue'
export { default as RoutingModelPolicyEditor } from './RoutingModelPolicyEditor.vue'
export { default as RoutingPriorityPolicyEditor } from './RoutingPriorityPolicyEditor.vue'
@@ -0,0 +1,63 @@
export const MAX_ROUTING_FAILOVER_RULES = 64
export const MAX_ROUTING_FAILOVER_PATTERN_BYTES = 4096
export interface RoutingFailoverRule {
pattern: string
status_codes: number[]
}
export interface RoutingFailoverRules {
success_failover_patterns: RoutingFailoverRule[]
error_stop_patterns: RoutingFailoverRule[]
}
export interface RoutingFailoverPolicy {
max_transfer_count: number
max_transfer_timeout_seconds: number
failover_rules: RoutingFailoverRules
}
export function normalizeRoutingFailoverPolicy(value?: Partial<RoutingFailoverPolicy>): RoutingFailoverPolicy {
const rules = value?.failover_rules
const cloneRules = (entries?: RoutingFailoverRule[]): RoutingFailoverRule[] => (
Array.isArray(entries) ? entries.map(rule => ({
pattern: String(rule.pattern ?? ''),
status_codes: Array.isArray(rule.status_codes) ? [...rule.status_codes] : [],
})) : []
)
return {
max_transfer_count: Number(value?.max_transfer_count ?? 0),
max_transfer_timeout_seconds: Number(value?.max_transfer_timeout_seconds ?? 0),
failover_rules: {
success_failover_patterns: cloneRules(rules?.success_failover_patterns),
error_stop_patterns: cloneRules(rules?.error_stop_patterns),
},
}
}
export function validateRoutingFailoverPolicy(policy: RoutingFailoverPolicy): string | null {
for (const [name, value] of [
['全局最大转移次数', policy.max_transfer_count],
['全局最大转移时间', policy.max_transfer_timeout_seconds],
] as const) {
if (!Number.isSafeInteger(value) || value < 0) return `${name}必须是非负整数`
}
for (const [name, entries, success] of [
['成功转移规则', policy.failover_rules.success_failover_patterns, true],
['错误提前终止规则', policy.failover_rules.error_stop_patterns, false],
] as const) {
if (entries.length > MAX_ROUTING_FAILOVER_RULES) return `${name}最多 ${MAX_ROUTING_FAILOVER_RULES} 条`
for (const [index, rule] of entries.entries()) {
if (!rule.pattern.trim() && (success || rule.status_codes.length === 0)) {
return `${name}第 ${index + 1} 条需要${success ? '正则表达式' : '状态码或正则表达式'}`
}
if (new TextEncoder().encode(rule.pattern.trim()).length > MAX_ROUTING_FAILOVER_PATTERN_BYTES) {
return `${name}第 ${index + 1} 条正则过长`
}
if (rule.status_codes.some(status => !Number.isInteger(status) || (success ? status !== 200 : status < 400 || status > 599))) {
return `${name}第 ${index + 1} 条状态码必须为${success ? ' 200' : ' 400–599'}`
}
}
}
return null
}
@@ -1,3 +1,5 @@
import { normalizeRoutingFailoverPolicy, type RoutingFailoverPolicy } from './routingFailover'
export type RoutingPriorityMode = 'provider' | 'global_key'
export type RoutingSchedulingMode = 'fixed_order' | 'cache_affinity' | 'load_balance'
export type RoutingRulePhase = 'client_request' | 'provider_request'
@@ -6,7 +8,7 @@ export type RoutingSortingScope = 'unified' | 'per_model'
/** 首个候选(粘性 Key)的总尝试次数默认值:失败后同 Key 重试 1 次 */
export const DEFAULT_STICKY_KEY_ATTEMPTS = 2
export interface RoutingDefaultPolicy {
export interface RoutingDefaultPolicy extends RoutingFailoverPolicy {
priority_mode: RoutingPriorityMode
scheduling_mode: RoutingSchedulingMode
keep_priority_on_conversion: boolean
@@ -74,6 +76,7 @@ export const MODEL_SCHEDULING_RULE_PREFIX = 'ui_model_scheduling:'
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
return {
default_policy: {
...normalizeRoutingFailoverPolicy(),
priority_mode: 'provider',
scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false,
@@ -122,6 +125,7 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
default_policy: {
...base.default_policy,
...defaultPolicyWithoutLegacyHeartbeat,
...normalizeRoutingFailoverPolicy(rawDefaultPolicy),
enable_cf_heartbeat: Boolean(
rawDefaultPolicy.enable_cf_heartbeat || legacyImageHeartbeat || legacyTextHeartbeat,
),
@@ -371,6 +375,7 @@ export function getModelScheduling(
const rule = normalized.rules.find(rule => rule.id === modelSchedulingRuleId(model))
const action = rule?.actions.find(isSetSchedulingAction)
return {
...normalized.default_policy,
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode,
keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion,
+16 -1
View File
@@ -500,6 +500,13 @@
</div>
</section>
<RoutingFailoverPolicyEditor
ref="routingFailoverPolicyEditor"
:model-value="draft.config_json.default_policy"
:disabled="saving"
@update:model-value="draft.config_json.default_policy = { ...draft.config_json.default_policy, ...$event }"
/>
<section class="space-y-4 rounded-lg border border-border/60 p-4">
<div>
<h3 class="text-sm font-medium">
@@ -882,7 +889,8 @@ import {
type RoutingSchedulingMode,
type RoutingSortingScope,
} from '@/features/routing/utils/routingPolicy'
import { RoutingPriorityPolicyEditor } from '@/features/routing/components'
import { RoutingFailoverPolicyEditor, RoutingPriorityPolicyEditor } from '@/features/routing/components'
import { validateRoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
import {
createRoutingGroup,
deleteRoutingGroup,
@@ -926,6 +934,7 @@ const schedulingModes: Array<{ value: RoutingSchedulingMode; label: string }> =
const groups = ref<RoutingGroupRecord[]>([])
const selectedGroupId = ref<string | null>(null)
const draft = ref<RoutingGroupDraft | null>(null)
const routingFailoverPolicyEditor = ref<{ commitJsonDrafts: () => boolean } | null>(null)
const savedDraftSnapshot = ref<string | null>(null)
const sortingScope = ref<RoutingSortingScope>('unified')
const selectedPerModelName = ref<string | null>(null)
@@ -1648,7 +1657,13 @@ async function saveDraft(): Promise<void> {
showError('策略名称不能为空')
return
}
if (routingFailoverPolicyEditor.value && !routingFailoverPolicyEditor.value.commitJsonDrafts()) return
const config = cloneConfig(draft.value.config_json)
const failoverError = validateRoutingFailoverPolicy(config.default_policy)
if (failoverError) {
showError(failoverError)
return
}
if (sortingScope.value === 'per_model' && perModelPolicies.value.length === 0) {
showError('按模型排序时至少选择一个模型')
return