mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
feat(routing): add strategy failover controls
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user